diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index acbde31..4ef3d03 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -1972,6 +1972,108 @@ namespace jdi */ bool IsOnEdgeCursorLine(int x, int y, int& laneIndex) const; + /** + * @brief 面板的「逐信号量宽」结果(宽度算式与列几何的**唯一来源**)。 + * + * ⚠️⚠️ 历史上 `EdgePanelWidth`(决定面板宽)与 `EdgePanelLayoutFor`(决定列边界) + * 各自写了一份**同名同形**的 `UiMeasureTextWidth` 链(标签列 / 数值列 / 重采样列), + * 注释里写着"与 `EdgePanelWidth` 同一组算式 ⇒ 不会漂移" —— 但那是**靠人守**的约定: + * 改一处漏一处,面板变宽而列不动(或反之),表现为文字压到轨道上 / 右边留一大片空白。 + * 现在两者都读同一个 `PanelColumnMetrics`,**没有第二份算式**。 + */ + struct PanelColumnMetrics + { + /** 可见信号数(= 面板的数据行数上限;「自由标记」只算有固定点的信号)。 */ + std::size_t signalCount = 0; + /** 名称列最大宽度(已按 `kEdgePanelNameMaxWidth` 夹取)。 */ + float nameColumn = 0.0f; + /** 「边沿计数」表格 / 「中心轴」统计列:上升沿文本宽度(含“统计中…”)。 */ + float statRising = 0.0f; + /** 下降沿文本宽度。 */ + float statFalling = 0.0f; + /** + * @brief 参数行的**标签列**宽度(「均值滤波」/「弱峰调整」两种标签取最大)。 + * + * 两种面板共用同一列宽,这样同一个网格里切换面板时滑块不会横向跳动。 + * ⚠️ **不含**「重采样」的标签(它更长,见 `resampleLabel`):把它并进来会让 + * 均值/弱峰面板整体变宽 —— 历史行为就是分开算的,合并会改变像素。 + */ + float sliderLabel = 0.0f; + /** @brief 「重采样」四行的标签列宽度(四者取最大,比均值/弱峰的标签更长)。 */ + float resampleLabel = 0.0f; + /** + * @brief 参数行的**数值列**宽度(`100000` 这类最宽数值文本)。 + * + * 它同时是"数值文本的裁剪宽度"(绘制侧)与"宽度预算的一项"(宽度算式), + * 两处必须同值,否则数值会被截断或右侧留白。 + */ + float valueColumn = 0.0f; + /** + * @brief 「重采样」两行只读统计的数值列宽度(逐信号实测真实文本,含(频率)尾缀)。 + * + * 用户要求统计文本**不得省略**,所以这一列必须按真实内容量宽。 + */ + float resampleValueColumn = 0.0f; + }; + /** + * @brief 量取某网格某面板的逐信号列宽(只读、无副作用)。 + * + * ⚠️ 本函数会被鼠标移动的高频路径间接调用(`TopPanelAtPoint` / `SyncPanelOrder`), + * 因此**不得**逐条查询采样值:边沿统计只能走 `EdgeCountsFor` / `EdgeCenterCountsFor` + * 的缓存入口,枚举列只允许查 `SignalEnumText::Empty()`。 + * + * @param [IN] lane 目标网格。 + * @param [IN] kind 面板种类。 + * @return 列宽度量(`signalCount` 与各列宽度)。 + */ + PanelColumnMetrics MeasurePanelColumns(const Lane& lane, EdgePanelKind kind) const; + + /** + * @brief 依据已量好的 `PanelColumnMetrics` 计算面板宽度(宽度算式的**唯一实现**)。 + * + * `EdgePanelLayoutFor` 与 `EdgePanelWidth` 都走它,且前者会把同一份 metrics 继续用于 + * 列几何 ⇒ 宽度与列边界**不可能不一致**(旧结构里两份算式各量一遍,只要有一处改了 + * 就会出现"面板变宽而列不动")。 + * + * @param [IN] lane 目标网格。 + * @param [IN] kind 面板种类。 + * @param [IN] metrics 列宽度量(必须由 `MeasurePanelColumns(lane, kind)` 得到)。 + * @return 面板宽度(已夹到绘图区宽度之内)。 + */ + float EdgePanelWidthFor(const Lane& lane, EdgePanelKind kind, + const PanelColumnMetrics& metrics) const; + + /** + * @brief 「列表式面板」的一行**计划**(行高累加与行几何的**唯一来源**)。 + * + * ⚠️⚠️ 旧实现有**两趟独立遍历**:第一趟只累加内容高度(`meanContentHeight`), + * 第二趟才真正生成 `rows` 并累加 `rowY`。两趟的判定条件(可见性 / 勾选 / 收起 / + * 是否需要统计行 / 参数行数)必须**逐字一致**,任何一处写错就表现为 + * "内容区高度与实际画出来的行不一致"(底部留白、或最后一行被裁掉、或明明没内容却能滚动)。 + * 现在只遍历一次得到本结构,高度与几何都从它派生 ⇒ 结构上不可能不一致。 + */ + struct PanelRowPlan + { + SignalRef ref; /**< 该行的信号。 */ + bool checked = false; /**< 是否已勾选(决定展开三角与参数行是否存在)。 */ + bool collapsed = false; /**< 条目是否收起(收起时不生成参数行)。 */ + bool hasStats = false; /**< 是否有一行「上升 / 下降沿」统计(仅「中心轴」)。 */ + /** 参数行行数:0 = 不展开,2 = 两行滑块,4 = 「重采样」四行。 */ + int sliderRows = 0; + }; + /** + * @brief 生成某网格某「列表式」面板的逐信号行计划。 + * + * 「边沿计数」是表格、「自由标记」是分组结构,两者都**不**使用本函数。 + * 「选区截取」的行不可展开 ⇒ `sliderRows` 恒为 0(是否生成行由调用方按 + * 「双光标是否就绪」决定:未就绪时整个计划作废,面板只显示一行提示)。 + * + * @param [IN] lane 目标网格。 + * @param [IN] kind 面板种类。 + * @return 行计划(顺序 = 从上到下)。 + */ + std::vector PlanListPanelRows(const Lane& lane, EdgePanelKind kind) const; + /** * @brief 实验性功能浮动面板的几何布局(三类面板共用同一套样式)。 */ @@ -2315,6 +2417,43 @@ namespace jdi std::size_t laneIndex, const SignalRef& ref, const Lane::ResampleItem& item); + /** + * @brief 「列表式面板」**单个信号参数行**绘制所需的逐行状态(一次收集、一次传入)。 + * + * 打包成结构的目的是把 `PaintPanelParamRows` 的参数表压到 5 个 + * (`g` / `layout` / `row` / `kind` / `state`):参数行的绘制需要"解析结果 + 两种条目 + * 指针 + 网格下标"四样东西,逐个透传会让签名比函数体更难读。 + */ + struct PanelParamState + { + /** 该信号的解析结果(「中心轴」统计行要用 `axis` / `data` / `style`)。 */ + const ResolvedSignal* resolved = nullptr; + /** 「均值滤波」/「中心轴边沿计数」的逐信号参数(未勾选时为空)。 */ + const Lane::MeanFilterItem* meanItem = nullptr; + /** 「弱峰调整」的逐信号参数(未勾选时为空)。 */ + const Lane::WeakPeakItem* peakItem = nullptr; + /** 网格下标(与 `m_meanDragLane` / `m_editLane` 等状态比对)。 */ + std::size_t laneIndex = 0; + }; + + /** + * @brief 「列表式面板」**单个信号的参数行**(中心轴统计行 + 两行滑块)。 + * + * 从 `PaintPanelListRows` 里提取(第 40 轮):三行/两行的滑块排版(标签 / 轨道 / 数值 / + * 复位按钮,含拖拽态、悬停态、编辑态)与"条目栏"没有共同点,留在同一个函数里只会让 + * 行循环膨胀到三百行。**零逻辑改动**,判据 = `panels -mode compare` + `rowgeom -mode compare`。 + * 定义在 `PlotViewPanelPaint.cpp`(与另外两个内容函数同一分片)。 + * + * @param [IN,OUT] g 绘图上下文(调用方已按内容区裁剪)。 + * @param [IN] layout 面板布局(内容区裁剪矩形、滑块/统计行矩形都来自它)。 + * @param [IN] row 该行的几何(`stats` / `levelTrack` / `iterTrack` 等字段)。 + * @param [IN] kind 面板种类(CENTER / MEAN / PEAK)。 + * @param [IN] state 逐行状态(解析结果 / 条目指针 / 网格下标)。 + */ + void PaintPanelParamRows(gd::Graphics& g, const EdgePanelLayout& layout, + const EdgePanelLayout::PanelRow& row, EdgePanelKind kind, + const PanelParamState& state); + /** * @brief 「自由标记」面板的**内容区**(分组栏 + 表头 + 条目 + 吸顶)。 * diff --git a/Components/PlotView/Src/PlotViewPanelPaint.cpp b/Components/PlotView/Src/PlotViewPanelPaint.cpp index 72f65e4..bf217ef 100644 --- a/Components/PlotView/Src/PlotViewPanelPaint.cpp +++ b/Components/PlotView/Src/PlotViewPanelPaint.cpp @@ -118,369 +118,373 @@ namespace jdi * rest: two slider rows). */ void PlotView::PaintPanelListRows(gd::Graphics& g, const EdgePanelLayout& layout, - const Lane& tabLane, EdgePanelKind kind, - std::size_t laneIndex) + const Lane& tabLane, EdgePanelKind kind, + std::size_t laneIndex) { const bool centerAxis = (kind == EdgePanelKind::CENTER); const bool weakPeak = (kind == EdgePanelKind::PEAK); const bool resamplePanel = (kind == EdgePanelKind::RESAMPLE); const bool clipPanel = (kind == EdgePanelKind::CLIP); const std::size_t index = laneIndex; - // "Rising / falling" two-column layout (fixed column origins => strictly aligned). - // NOTE: must use UiTextClipped -- GDI text ignores GDI+ SetClip. + const int meanSave = g.Save(); + // 内容必须裁到内容区:滚动时行会越出可视范围。 + g.SetClip(layout.content, gd::CombineModeIntersect); + + // 复选框自绘(主题色在信号色叠底上看不清):用**自适应对比色**描边/填充。 + const auto paintMeanCheck = [this, &g](const gd::RectF& rect, bool checked, const UiInk& ink, + std::uint32_t background) + { + if (rect.Width <= 0.0f || rect.Height <= 0.0f) + { + return; + } + const float radius = 2.5f * m_scale; + if (!checked) + { + UiDrawRoundRect(g, rect, radius, ink.text, 1.2f * m_scale); + return; + } + UiFillRoundRect(g, rect, radius, ink.text); + // 对勾:两段折线,颜色取叠底色(与填充色必成对比)。 + const float left = rect.X + rect.Width * 0.22f; + const float middleX = rect.X + rect.Width * 0.44f; + const float right = rect.X + rect.Width * 0.78f; + const float highY = rect.Y + rect.Height * 0.30f; + const float lowY = rect.Y + rect.Height * 0.72f; + UiDrawLine(g, left, rect.Y + rect.Height * 0.52f, middleX, lowY, background, + 1.6f * m_scale); + UiDrawLine(g, middleX, lowY, right, highY, background, 1.6f * m_scale); + }; + + for (std::size_t rowIndex = 0; rowIndex < layout.rows.size(); ++rowIndex) + { + const SignalRef& ref = layout.rows[rowIndex].ref; + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || resolved.style == nullptr) + { + continue; + } + const gd::RectF& header = layout.rows[rowIndex].header; + // 行的**可见性**必须按“整行的纵向范围”判定,不能只看条目栏: + // 条目栏已滚出内容区,但它下面两行参数滑块仍可能还在可视区内 + //(用户 2026-10-30 报告:此时两个滑块会直接消失 —— 显示错误)。 + // 整行的纵向范围 = 条目栏 ∪ 全部参数行(未使用的字段恒为空矩形,直接 extent 即可; + // 旧结构里每一项都要先判断"数组可能更短",现在一行就是一个整体)。 + const EdgePanelLayout::PanelRow& row = layout.rows[rowIndex]; + float rowTop = row.header.Y; + float rowBottom = row.header.Y + row.header.Height; + const auto extendRow = [&rowBottom](const gd::RectF& rect) { + if (rect.Width > 0.0f && rect.Height > 0.0f) + { + rowBottom = std::max(rowBottom, rect.Y + rect.Height); + } + }; + extendRow(row.levelTrack); + extendRow(row.levelValue); + extendRow(row.levelReset); + extendRow(row.iterTrack); + extendRow(row.iterValue); + extendRow(row.iterReset); + extendRow(row.stats); + extendRow(row.infoMaxInterval); + extendRow(row.infoMinInterval); + extendRow(row.intervalField); + extendRow(row.frequencyField); + if (rowBottom <= layout.content.Y || + rowTop >= layout.content.Y + layout.content.Height) + { + continue; // 整行(含全部参数行)完全滚出可视范围 + } + const std::uint32_t signalColor = StyleColor(*resolved.style); + const UiInk ink = UiContrastInk(signalColor); + // 「均值滤波」看 `meanFilterItems`、「中心轴边沿计数」看 `edgeCenterItems`、 + //「弱峰调整」看 `weakPeakItems`(前两者同为 `Lane::MeanFilterItem`)。 + const Lane::WeakPeakItem* peakItem = + weakPeak ? tabLane.FindWeakPeakItem(ref) : nullptr; + const Lane::MeanFilterItem* item = + weakPeak ? nullptr + : (centerAxis ? tabLane.FindEdgeCenterItem(ref) + : tabLane.FindMeanFilterItem(ref)); + const Lane::ResampleItem* resampleItem = + resamplePanel ? tabLane.FindResampleItem(ref) : nullptr; + const bool checked = clipPanel ? tabLane.ClipEnabled(ref) + : (resamplePanel ? (resampleItem != nullptr) + : (weakPeak ? (peakItem != nullptr) + : (item != nullptr))); + const bool rowCollapsed = + clipPanel ? true + : (resamplePanel ? (resampleItem != nullptr && resampleItem->collapsed) + : (weakPeak ? (peakItem != nullptr && peakItem->collapsed) + : (item != nullptr && item->collapsed))); + + // 内容区已由 `g.SetClip(layout.content)` 裁切(行可以“半截”显示,滚动更顺滑); + // 已经完整滚出可视范围的行在上面就 `continue` 了,这里不再额外门控。 + UiFillRoundRect(g, header, 3.0f * m_scale, signalColor); + UiFillRoundRect(g, header, 3.0f * m_scale, WithAlpha(signalColor, 0xE8)); + // 条目栏悬停:在信号色叠底上再压一层强调色,给出"这一行对应哪条曲线"的提示 + //(曲线本身的高亮见 `PaintLaneDerivedOverlays`)。 + // ⚠️ 必须同时比**面板种类**:同一条信号在其它面板里也有一行, + // 只比 (lane, ref) 会把别的面板的同名行一起点亮。 + if (m_meanRowHoverLane == static_cast(index) && + m_meanRowHoverRef == ref && m_meanRowHoverKind == kind) + { + UiFillRoundRect(g, header, 3.0f * m_scale, + WithAlpha(m_theme->accent, 0x2E)); + } + + // 左侧三角(仅勾选时存在):朝下 = 展开、朝右 = 收起。 + const gd::RectF& triangle = layout.rows[rowIndex].triangle; + if (triangle.Width > 0.0f && checked) + { + UiDrawTriangleIcon(g, triangle, ink.text, + rowCollapsed ? IconDirection::RIGHT + : IconDirection::DOWN); + } + + // 名称:从三角右侧到复选框左侧。 + const gd::RectF& check = layout.rows[rowIndex].check; + const float nameLeft = (triangle.Width > 0.0f) + ? (triangle.X + triangle.Width + 6.0f * m_scale) + : (header.X + 8.0f * m_scale); + const float nameWidth = + std::max(10.0f, check.X - 6.0f * m_scale - nameLeft); + UiTextClipped(g, UiEllipsize(resolved.data->name, m_fonts->uiSmall, nameWidth), + m_fonts->uiSmall, + gd::RectF(nameLeft, header.Y, nameWidth, header.Height), ink.text, + TextAlign::LEFT, layout.content); + paintMeanCheck(check, checked, ink, signalColor); + + if (!checked || rowCollapsed) + { + continue; + } + + // 「重采样」:四行 = 最大间隔(只读)/ 最小间隔(只读)/ + // 重采样间隔(输入)/ 重采样频率(输入)。标签在左、数值在右。 + // 排布细节见 `PaintPanelResampleRows`(从本循环里提取的独立函数)。 + if (resamplePanel) + { + PaintPanelResampleRows(g, layout, rowIndex, index, ref, *resampleItem); + continue; + } + + // 参数行(中心轴统计行 + 两行滑块)见 `PaintPanelParamRows`: + // 三/两行的滑块排版与"条目栏"没有共同点,留在本循环里只会让行循环膨胀到三百行。 + PaintPanelParamRows(g, layout, row, kind, + PanelParamState{&resolved, item, peakItem, index}); + } + + g.Restore(meanSave); + + // ---- 内容滚动条(只在内容超高时出现;底部按钮不参与滚动)---- + // ⚠️ 必须在 `g.Restore(meanSave)` **之后**画:滚动条位于内容区右侧、在 + // 内容裁剪区之外,若在裁剪内画会被整条裁掉(用户 2026-10-30 报告 + // "全部启用后没有出现滚动条"的根因)。 + PaintPanelScrollBar(g, layout, *m_theme); + } + + /** + * @brief 「列表式面板」**单个信号的参数行**(中心轴统计行 + 两行滑块)。 + * + * 从 `PaintPanelListRows` 里整块提取(第 40 轮):滑块排版(标签 / 轨道 / 数值 / 复位按钮, + * 含拖拽态 / 悬停态 / 编辑态)与"条目栏"没有共同点。**零逻辑改动**。 + */ + void PlotView::PaintPanelParamRows(gd::Graphics& g, const EdgePanelLayout& layout, + const EdgePanelLayout::PanelRow& row, EdgePanelKind kind, + const PanelParamState& state) + { + const bool centerAxis = (kind == EdgePanelKind::CENTER); + const bool weakPeak = (kind == EdgePanelKind::PEAK); + const std::size_t index = state.laneIndex; + const ResolvedSignal& resolved = *state.resolved; + const Lane::MeanFilterItem* item = state.meanItem; + const Lane::WeakPeakItem* peakItem = state.peakItem; + // 数值列的**裁剪宽度**:与布局里的 `metrics.valueColumn` 同值(`100000` 是最宽数值)。 + const float valueColumn = std::max(UiMeasureTextWidth(m_fonts->monoSmall, L"100000"), + UiMeasureTextWidth(m_fonts->monoSmall, L"1000")); + + // 「上升沿 / 下降沿」两列排版(列起点固定 ⇒ 多条信号严格垂直对齐)。 + // ⚠️ 必须用 `UiTextClipped`(而不是 `UiText`):GDI 文本不受 GDI+ `SetClip` 约束。 const auto paintCounts = [this, &g](const gd::RectF& rect, std::size_t rising, std::size_t falling, bool complete, const gd::RectF& clip) { if (EdgeCountsPending(rising, falling, complete)) { - UiTextClipped(g, L"\u7edf\u8ba1\u4e2d\u2026", m_fonts->monoSmall, rect, - m_theme->textMuted, TextAlign::LEFT, clip); + UiTextClipped(g, L"统计中…", m_fonts->monoSmall, rect, m_theme->textMuted, + TextAlign::LEFT, clip); return; } const float gap = kEdgePanelStatColumnGap * m_scale; const float columnWidth = std::max(10.0f, (rect.Width - gap) * 0.5f); UiTextClipped(g, EdgeRisingText(rising, complete), m_fonts->monoSmall, - gd::RectF(rect.X, rect.Y, columnWidth, rect.Height), - m_theme->textMuted, TextAlign::LEFT, clip); + gd::RectF(rect.X, rect.Y, columnWidth, rect.Height), m_theme->textMuted, + TextAlign::LEFT, clip); UiTextClipped(g, EdgeFallingText(falling, complete), m_fonts->monoSmall, - gd::RectF(rect.X + columnWidth + gap, rect.Y, columnWidth, - rect.Height), + gd::RectF(rect.X + columnWidth + gap, rect.Y, columnWidth, rect.Height), m_theme->textMuted, TextAlign::LEFT, clip); }; - const int meanSave = g.Save(); - // 内容必须裁到内容区:滚动时行会越出可视范围。 - g.SetClip(layout.content, gd::CombineModeIntersect); - const float valueColumn = - std::max(UiMeasureTextWidth(m_fonts->monoSmall, L"100000"), - UiMeasureTextWidth(m_fonts->monoSmall, L"1000")); + // 「中心轴边沿计数」:参数行最上面是**同一行**的「上升沿 / 下降沿」统计。 + if (centerAxis && row.stats.Width > 0.0f) + { + std::size_t rising = 0; + std::size_t falling = 0; + bool complete = true; + if (resolved.axis != nullptr) + { + EdgeCenterCountsFor(*resolved.axis, *resolved.data, *resolved.style, item->points, + item->iterations, EdgeRangeActive(), EdgeRangeMin(), + EdgeRangeMax(), rising, falling, complete); + } + paintCounts(row.stats, rising, falling, complete, layout.content); + } - // 复选框自绘(主题色在信号色叠底上看不清):用**自适应对比色**描边/填充。 - const auto paintMeanCheck = [this, &g](const gd::RectF& rect, bool checked, - const UiInk& ink, - std::uint32_t background) + // 两行参数滑块(标签 / 轨道 / 数值 / 复位按钮)。 + const auto paintMeanSlider = [&](const gd::RectF& track, const gd::RectF& valueRect, + const gd::RectF& resetRect, const wchar_t* label, + int current, int minimum, int maximum, bool active) + { + const float labelLeft = track.X - 6.0f * m_scale - + UiMeasureTextWidth(m_fonts->uiSmall, label); + // ⚠️ **必须用 `UiTextClipped`(而不是 `UiText`)**:GDI 文本不受 + // GDI+ `SetClip` 约束,滚动时标签/数值会画到内容区之外 + //(用户 2026-10-30 报告"元素超出了浮动面板"的直接原因)。 + UiTextClipped(g, label, m_fonts->uiSmall, + gd::RectF(labelLeft, track.Y - 8.0f * m_scale, + UiMeasureTextWidth(m_fonts->uiSmall, label), + track.Height + 16.0f * m_scale), + m_theme->textMuted, TextAlign::LEFT, layout.content); + const double ratio = + (maximum > minimum) ? (static_cast(current - minimum) / + static_cast(maximum - minimum)) + : 0.0; + UiDrawSlider(g, track, ratio, m_theme->accent, active, m_scale); + UiTextClipped(g, FormatInteger(current), m_fonts->monoSmall, + gd::RectF(valueRect.X, valueRect.Y, valueColumn, valueRect.Height), + m_theme->textStrong, TextAlign::LEFT, layout.content); + // 复位按钮:悬停高亮 + 与「系数/偏移」菜单同款图标。 + const bool hot = + (m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) && + UiPointInRect(resetRect, static_cast(m_mouseX), static_cast(m_mouseY)); + if (hot) + { + UiFillRoundRect(g, + gd::RectF(resetRect.X - 1.0f * m_scale, + resetRect.Y - 1.0f * m_scale, + resetRect.Width + 2.0f * m_scale, + resetRect.Height + 2.0f * m_scale), + 3.0f * m_scale, WithAlpha(m_theme->accent, 0x33)); + } + PaintPanelResetIcon(g, resetRect, hot ? m_theme->accent : m_theme->textMuted, m_scale); + }; + + // ⚠️ 「均值点数」的最小值:**均值滤波 = 1**(用户 2026-10-30 要求不能为 0, + // 0 与不做平滑完全等价,看起来像功能坏了);**中心轴边沿计数 = 0** + // (0 = 不平滑,直接对原始采样值统计边沿 —— 这是有意义的选项,保持旧语义)。 + if (weakPeak) + { + // 「弱峰调整」两行滑块:调整幅度(0 在正中间 ⇒ ±400%)与迭代次数(1 … 自定上限)。 + const int peakAmount = + std::max(kWeakPeakAmountMin, std::min(kWeakPeakAmountMax, peakItem->amountPercent)); + const int peakMax = + std::max(kWeakPeakIterationsMin, + std::min(kWeakPeakIterationsMaxLimit, peakItem->maxIterations)); + const int peakIter = + std::max(kWeakPeakIterationsMin, std::min(peakMax, peakItem->iterations)); + const bool levelActive = (m_dragMode == PlotDragMode::PEAK_LEVEL) && + (m_peakDragLane == static_cast(index)) && + (m_peakDragRef == row.ref); + const bool iterActive = (m_dragMode == PlotDragMode::PEAK_ITERATIONS) && + (m_peakDragLane == static_cast(index)) && + (m_peakDragRef == row.ref); + const bool editingMax = (m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX) && + (m_editLane == static_cast(index)) && + (m_editMeanRef == row.ref); + // 幅度滑块:比例 = (幅度 / (100*4) + 1) / 2(0% 在正中间)。 + const auto paintPeakSlider = [&](const gd::RectF& track, const gd::RectF& valueRect, + const gd::RectF& resetRect, const wchar_t* label, + double ratio, const std::wstring& valueText, + bool active, bool hideBody) + { + const float labelLeft = track.X - 6.0f * m_scale - + UiMeasureTextWidth(m_fonts->uiSmall, label); + UiTextClipped(g, label, m_fonts->uiSmall, + gd::RectF(labelLeft, track.Y - 8.0f * m_scale, + UiMeasureTextWidth(m_fonts->uiSmall, label), + track.Height + 16.0f * m_scale), + m_theme->textMuted, TextAlign::LEFT, layout.content); + if (hideBody) { - if (rect.Width <= 0.0f || rect.Height <= 0.0f) - { - return; - } - const float radius = 2.5f * m_scale; - if (!checked) - { - UiDrawRoundRect(g, rect, radius, ink.text, 1.2f * m_scale); - return; - } - UiFillRoundRect(g, rect, radius, ink.text); - // 对勾:两段折线,颜色取叠底色(与填充色必成对比)。 - const float left = rect.X + rect.Width * 0.22f; - const float middleX = rect.X + rect.Width * 0.44f; - const float right = rect.X + rect.Width * 0.78f; - const float highY = rect.Y + rect.Height * 0.30f; - const float lowY = rect.Y + rect.Height * 0.72f; - UiDrawLine(g, left, rect.Y + rect.Height * 0.52f, middleX, lowY, background, - 1.6f * m_scale); - UiDrawLine(g, middleX, lowY, right, highY, background, 1.6f * m_scale); - }; - - for (std::size_t rowIndex = 0; rowIndex < layout.rows.size(); ++rowIndex) - { - const SignalRef& ref = layout.rows[rowIndex].ref; - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data == nullptr || resolved.style == nullptr) - { - continue; - } - const gd::RectF& header = layout.rows[rowIndex].header; - // 行的**可见性**必须按“整行的纵向范围”判定,不能只看条目栏: - // 条目栏已滚出内容区,但它下面两行参数滑块仍可能还在可视区内 - //(用户 2026-10-30 报告:此时两个滑块会直接消失 —— 显示错误)。 - // 整行的纵向范围 = 条目栏 ∪ 全部参数行(未使用的字段恒为空矩形,直接 extent 即可; - // 旧结构里每一项都要先判断"数组可能更短",现在一行就是一个整体)。 - const EdgePanelLayout::PanelRow& row = layout.rows[rowIndex]; - float rowTop = row.header.Y; - float rowBottom = row.header.Y + row.header.Height; - const auto extendRow = [&rowBottom](const gd::RectF& rect) { - if (rect.Width > 0.0f && rect.Height > 0.0f) - { - rowBottom = std::max(rowBottom, rect.Y + rect.Height); - } - }; - extendRow(row.levelTrack); - extendRow(row.levelValue); - extendRow(row.levelReset); - extendRow(row.iterTrack); - extendRow(row.iterValue); - extendRow(row.iterReset); - extendRow(row.stats); - extendRow(row.infoMaxInterval); - extendRow(row.infoMinInterval); - extendRow(row.intervalField); - extendRow(row.frequencyField); - if (rowBottom <= layout.content.Y || - rowTop >= layout.content.Y + layout.content.Height) - { - continue; // 整行(含全部参数行)完全滚出可视范围 - } - const std::uint32_t signalColor = StyleColor(*resolved.style); - const UiInk ink = UiContrastInk(signalColor); - // 「均值滤波」看 `meanFilterItems`、「中心轴边沿计数」看 `edgeCenterItems`、 - //「弱峰调整」看 `weakPeakItems`(前两者同为 `Lane::MeanFilterItem`)。 - const Lane::WeakPeakItem* peakItem = - weakPeak ? tabLane.FindWeakPeakItem(ref) : nullptr; - const Lane::MeanFilterItem* item = - weakPeak ? nullptr - : (centerAxis ? tabLane.FindEdgeCenterItem(ref) - : tabLane.FindMeanFilterItem(ref)); - const Lane::ResampleItem* resampleItem = - resamplePanel ? tabLane.FindResampleItem(ref) : nullptr; - const bool checked = clipPanel ? tabLane.ClipEnabled(ref) - : (resamplePanel ? (resampleItem != nullptr) - : (weakPeak ? (peakItem != nullptr) - : (item != nullptr))); - const bool rowCollapsed = - clipPanel ? true - : (resamplePanel ? (resampleItem != nullptr && resampleItem->collapsed) - : (weakPeak ? (peakItem != nullptr && peakItem->collapsed) - : (item != nullptr && item->collapsed))); - - // 内容区已由 `g.SetClip(layout.content)` 裁切(行可以“半截”显示,滚动更顺滑); - // 已经完整滚出可视范围的行在上面就 `continue` 了,这里不再额外门控。 - UiFillRoundRect(g, header, 3.0f * m_scale, signalColor); - UiFillRoundRect(g, header, 3.0f * m_scale, WithAlpha(signalColor, 0xE8)); - // 条目栏悬停:在信号色叠底上再压一层强调色,给出"这一行对应哪条曲线"的提示 - //(曲线本身的高亮见 `PaintLaneDerivedOverlays`)。 - // ⚠️ 必须同时比**面板种类**:同一条信号在其它面板里也有一行, - // 只比 (lane, ref) 会把别的面板的同名行一起点亮。 - if (m_meanRowHoverLane == static_cast(index) && - m_meanRowHoverRef == ref && m_meanRowHoverKind == kind) - { - UiFillRoundRect(g, header, 3.0f * m_scale, - WithAlpha(m_theme->accent, 0x2E)); - } - - // 左侧三角(仅勾选时存在):朝下 = 展开、朝右 = 收起。 - const gd::RectF& triangle = layout.rows[rowIndex].triangle; - if (triangle.Width > 0.0f && checked) - { - UiDrawTriangleIcon(g, triangle, ink.text, - rowCollapsed ? IconDirection::RIGHT - : IconDirection::DOWN); - } - - // 名称:从三角右侧到复选框左侧。 - const gd::RectF& check = layout.rows[rowIndex].check; - const float nameLeft = (triangle.Width > 0.0f) - ? (triangle.X + triangle.Width + 6.0f * m_scale) - : (header.X + 8.0f * m_scale); - const float nameWidth = - std::max(10.0f, check.X - 6.0f * m_scale - nameLeft); - UiTextClipped(g, UiEllipsize(resolved.data->name, m_fonts->uiSmall, nameWidth), - m_fonts->uiSmall, - gd::RectF(nameLeft, header.Y, nameWidth, header.Height), ink.text, - TextAlign::LEFT, layout.content); - paintMeanCheck(check, checked, ink, signalColor); - - if (!checked || rowCollapsed) - { - continue; - } - - // 「重采样」:四行 = 最大间隔(只读)/ 最小间隔(只读)/ - // 重采样间隔(输入)/ 重采样频率(输入)。标签在左、数值在右。 - // 排布细节见 `PaintPanelResampleRows`(从本循环里提取的独立函数)。 - if (resamplePanel) - { - PaintPanelResampleRows(g, layout, rowIndex, index, ref, *resampleItem); - continue; - } - - // 「中心轴边沿计数」:参数行最上面是**同一行**的「上升沿 / 下降沿」统计。 - if (centerAxis && layout.rows[rowIndex].stats.Width > 0.0f) - { - std::size_t rising = 0; - std::size_t falling = 0; - bool complete = true; - if (resolved.axis != nullptr) - { - EdgeCenterCountsFor(*resolved.axis, *resolved.data, *resolved.style, - item->points, item->iterations, EdgeRangeActive(), - EdgeRangeMin(), EdgeRangeMax(), rising, falling, complete); - } - paintCounts(layout.rows[rowIndex].stats, rising, falling, complete, - layout.content); - } - - // 两行参数滑块(标签 / 轨道 / 数值 / 复位按钮)。 - const auto paintMeanSlider = [&](const gd::RectF& track, - const gd::RectF& valueRect, - const gd::RectF& resetRect, - const wchar_t* label, int current, int minimum, - int maximum, bool active) - { - const float labelLeft = track.X - 6.0f * m_scale - - UiMeasureTextWidth(m_fonts->uiSmall, label); - // ⚠️ **必须用 `UiTextClipped`(而不是 `UiText`)**:GDI 文本不受 - // GDI+ `SetClip` 约束,滚动时标签/数值会画到内容区之外 - //(用户 2026-10-30 报告"元素超出了浮动面板"的直接原因)。 - UiTextClipped(g, label, m_fonts->uiSmall, - gd::RectF(labelLeft, track.Y - 8.0f * m_scale, - UiMeasureTextWidth(m_fonts->uiSmall, label), - track.Height + 16.0f * m_scale), - m_theme->textMuted, TextAlign::LEFT, layout.content); - const double ratio = - (maximum > minimum) - ? (static_cast(current - minimum) / - static_cast(maximum - minimum)) - : 0.0; - UiDrawSlider(g, track, ratio, m_theme->accent, active, m_scale); - UiTextClipped(g, FormatInteger(current), m_fonts->monoSmall, - gd::RectF(valueRect.X, valueRect.Y, valueColumn, valueRect.Height), - m_theme->textStrong, TextAlign::LEFT, layout.content); - // 复位按钮:悬停高亮 + 与「系数/偏移」菜单同款图标。 - const bool hot = - (m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) && - UiPointInRect(resetRect, static_cast(m_mouseX), - static_cast(m_mouseY)); - if (hot) - { - UiFillRoundRect( - g, gd::RectF(resetRect.X - 1.0f * m_scale, - resetRect.Y - 1.0f * m_scale, - resetRect.Width + 2.0f * m_scale, - resetRect.Height + 2.0f * m_scale), - 3.0f * m_scale, WithAlpha(m_theme->accent, 0x33)); - } - PaintPanelResetIcon(g, resetRect, - hot ? m_theme->accent : m_theme->textMuted, m_scale); - }; - - // ⚠️ 「均值点数」的最小值:**均值滤波 = 1**(用户 2026-10-30 要求不能为 0, - // 0 与不做平滑完全等价,看起来像功能坏了);**中心轴边沿计数 = 0** - // (0 = 不平滑,直接对原始采样值统计边沿 —— 这是有意义的选项,保持旧语义)。 - if (weakPeak) - { - // 「弱峰调整」两行滑块:调整幅度(0 在正中间 ⇒ ±400%)与迭代次数(1 … 自定上限)。 - const int peakAmount = std::max( - kWeakPeakAmountMin, std::min(kWeakPeakAmountMax, peakItem->amountPercent)); - const int peakMax = std::max( - kWeakPeakIterationsMin, - std::min(kWeakPeakIterationsMaxLimit, peakItem->maxIterations)); - const int peakIter = - std::max(kWeakPeakIterationsMin, std::min(peakMax, peakItem->iterations)); - const bool levelActive = (m_dragMode == PlotDragMode::PEAK_LEVEL) && - (m_peakDragLane == static_cast(index)) && - (m_peakDragRef == ref); - const bool iterActive = (m_dragMode == PlotDragMode::PEAK_ITERATIONS) && - (m_peakDragLane == static_cast(index)) && - (m_peakDragRef == ref); - const bool editingMax = - (m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX) && - (m_editLane == static_cast(index)) && (m_editMeanRef == ref); - // 幅度滑块:比例 = (幅度 / (100*4) + 1) / 2(0% 在正中间)。 - const auto paintPeakSlider = [&](const gd::RectF& track, - const gd::RectF& valueRect, - const gd::RectF& resetRect, - const wchar_t* label, double ratio, - const std::wstring& valueText, bool active, - bool hideBody) - { - const float labelLeft = track.X - 6.0f * m_scale - - UiMeasureTextWidth(m_fonts->uiSmall, label); - UiTextClipped(g, label, m_fonts->uiSmall, - gd::RectF(labelLeft, track.Y - 8.0f * m_scale, - UiMeasureTextWidth(m_fonts->uiSmall, label), - track.Height + 16.0f * m_scale), - m_theme->textMuted, TextAlign::LEFT, layout.content); - if (hideBody) - { - return; // 正在编辑上限:轨道与数值改由 `PaintEditField` 画 - } - UiDrawSlider(g, track, ratio, m_theme->accent, active, m_scale); - UiTextClipped(g, valueText, m_fonts->monoSmall, - gd::RectF(valueRect.X, valueRect.Y, valueColumn, - valueRect.Height), - m_theme->textStrong, TextAlign::LEFT, layout.content); - const bool hot = (m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) && - UiPointInRect(resetRect, - static_cast(m_mouseX), - static_cast(m_mouseY)); - if (hot) - { - UiFillRoundRect( - g, gd::RectF(resetRect.X - 1.0f * m_scale, - resetRect.Y - 1.0f * m_scale, - resetRect.Width + 2.0f * m_scale, - resetRect.Height + 2.0f * m_scale), - 3.0f * m_scale, WithAlpha(m_theme->accent, 0x33)); - } - PaintPanelResetIcon(g, resetRect, - hot ? m_theme->accent : m_theme->textMuted, - m_scale); - }; - const double levelRatio = - (static_cast(peakAmount) / (100.0 * kWeakPeakSliderScale) + 1.0) * 0.5; - const double iterRatio = - (peakMax > kWeakPeakIterationsMin) - ? (static_cast(peakIter - kWeakPeakIterationsMin) / - static_cast(peakMax - kWeakPeakIterationsMin)) - : 0.0; - if (row.levelTrack.Width > 0.0f) - { - paintPeakSlider(row.levelTrack, row.levelValue, row.levelReset, - kWeakPeakLevelLabel, levelRatio, - (peakAmount > 0 ? L"+" : L"") + FormatInteger(peakAmount) + L"%", - levelActive, false); - } - if (row.iterTrack.Width > 0.0f) - { - paintPeakSlider(row.iterTrack, row.iterValue, row.iterReset, - kWeakPeakIterationsSliderLabel, iterRatio, - FormatInteger(peakIter), iterActive, editingMax); - } - } - else - { - // 只可能是「均值滤波」:中心轴边沿计数已改为独立的表格分支。 - const int pointsFloor = kMeanFilterPointsMin; - const int pointsMax = std::max(pointsFloor, - std::min(kMeanFilterPointsMaxLimit, - item->maxPoints)); - const int points = - std::max(pointsFloor, std::min(pointsMax, item->points)); - const int iterationsMax = std::max(kMeanFilterIterationsMin, - std::min(kMeanFilterIterationsMaxLimit, - item->maxIterations)); - const int iterations = std::max( - kMeanFilterIterationsMin, std::min(iterationsMax, item->iterations)); - const bool pointsActive = (m_dragMode == PlotDragMode::MEAN_POINTS) && - (m_meanDragLane == static_cast(index)) && - (m_meanDragRef == ref) && - (m_meanDragKind == kind); - const bool iterationsActive = - (m_dragMode == PlotDragMode::MEAN_ITERATIONS) && - (m_meanDragLane == static_cast(index)) && (m_meanDragRef == ref) && - (m_meanDragKind == kind); - paintMeanSlider(row.levelTrack, row.levelValue, row.levelReset, - kMeanFilterPointsSliderLabel, points, pointsFloor, pointsMax, - pointsActive); - paintMeanSlider(row.iterTrack, row.iterValue, row.iterReset, - kMeanFilterIterationsSliderLabel, iterations, - kMeanFilterIterationsMin, iterationsMax, iterationsActive); - } - + return; // 正在编辑上限:轨道与数值改由 `PaintEditField` 画 } + UiDrawSlider(g, track, ratio, m_theme->accent, active, m_scale); + UiTextClipped(g, valueText, m_fonts->monoSmall, + gd::RectF(valueRect.X, valueRect.Y, valueColumn, valueRect.Height), + m_theme->textStrong, TextAlign::LEFT, layout.content); + const bool hot = + (m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) && + UiPointInRect(resetRect, static_cast(m_mouseX), + static_cast(m_mouseY)); + if (hot) + { + UiFillRoundRect(g, + gd::RectF(resetRect.X - 1.0f * m_scale, + resetRect.Y - 1.0f * m_scale, + resetRect.Width + 2.0f * m_scale, + resetRect.Height + 2.0f * m_scale), + 3.0f * m_scale, WithAlpha(m_theme->accent, 0x33)); + } + PaintPanelResetIcon(g, resetRect, hot ? m_theme->accent : m_theme->textMuted, + m_scale); + }; + const double levelRatio = + (static_cast(peakAmount) / (100.0 * kWeakPeakSliderScale) + 1.0) * 0.5; + const double iterRatio = + (peakMax > kWeakPeakIterationsMin) + ? (static_cast(peakIter - kWeakPeakIterationsMin) / + static_cast(peakMax - kWeakPeakIterationsMin)) + : 0.0; + if (row.levelTrack.Width > 0.0f) + { + paintPeakSlider(row.levelTrack, row.levelValue, row.levelReset, kWeakPeakLevelLabel, + levelRatio, + (peakAmount > 0 ? L"+" : L"") + FormatInteger(peakAmount) + L"%", + levelActive, false); + } + if (row.iterTrack.Width > 0.0f) + { + paintPeakSlider(row.iterTrack, row.iterValue, row.iterReset, + kWeakPeakIterationsSliderLabel, iterRatio, FormatInteger(peakIter), + iterActive, editingMax); + } + return; + } - g.Restore(meanSave); - - // ---- 内容滚动条(只在内容超高时出现;底部按钮不参与滚动)---- - // ⚠️ 必须在 `g.Restore(meanSave)` **之后**画:滚动条位于内容区右侧、在 - // 内容裁剪区之外,若在裁剪内画会被整条裁掉(用户 2026-10-30 报告 - // "全部启用后没有出现滚动条"的根因)。 - PaintPanelScrollBar(g, layout, *m_theme); + // 只可能是「均值滤波」或「中心轴」:中心轴多一行统计(上面已画),滑块参数同形。 + const int pointsFloor = kMeanFilterPointsMin; + const int pointsMax = + std::max(pointsFloor, std::min(kMeanFilterPointsMaxLimit, item->maxPoints)); + const int points = std::max(pointsFloor, std::min(pointsMax, item->points)); + const int iterationsMax = + std::max(kMeanFilterIterationsMin, + std::min(kMeanFilterIterationsMaxLimit, item->maxIterations)); + const int iterations = + std::max(kMeanFilterIterationsMin, std::min(iterationsMax, item->iterations)); + const bool pointsActive = (m_dragMode == PlotDragMode::MEAN_POINTS) && + (m_meanDragLane == static_cast(index)) && + (m_meanDragRef == row.ref) && (m_meanDragKind == kind); + const bool iterationsActive = (m_dragMode == PlotDragMode::MEAN_ITERATIONS) && + (m_meanDragLane == static_cast(index)) && + (m_meanDragRef == row.ref) && (m_meanDragKind == kind); + paintMeanSlider(row.levelTrack, row.levelValue, row.levelReset, + kMeanFilterPointsSliderLabel, points, pointsFloor, pointsMax, pointsActive); + paintMeanSlider(row.iterTrack, row.iterValue, row.iterReset, + kMeanFilterIterationsSliderLabel, iterations, kMeanFilterIterationsMin, + iterationsMax, iterationsActive); } - /** +/** * @brief 「重采样」面板**单个信号的参数区**(四行)。 * * 从 `PaintPanelListRows` 里整块提取(第 38 轮):这四行的排布(两行只读统计 + diff --git a/Components/PlotView/Src/PlotViewPanels.cpp b/Components/PlotView/Src/PlotViewPanels.cpp index 1e5ca0f..fa06cce 100644 --- a/Components/PlotView/Src/PlotViewPanels.cpp +++ b/Components/PlotView/Src/PlotViewPanels.cpp @@ -40,6 +40,210 @@ namespace jdi return std::wstring(); } + PlotView::PanelColumnMetrics PlotView::MeasurePanelColumns(const Lane& lane, + EdgePanelKind kind) const + { + PanelColumnMetrics metrics; + const PanelStyle panelStyle = PanelStyleOf(kind); + const bool centerAxis = (kind == EdgePanelKind::CENTER); + const bool countTable = panelStyle.countTable; + const bool listStyle = panelStyle.listRows; + + // ---- 与信号无关的列宽(都是字体度量,量一次即可)---- + // 参数行标签:均值 / 弱峰两种面板共用一列(切换面板时滑块不横向跳动)。 + metrics.sliderLabel = + std::max(UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterPointsSliderLabel), + UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterIterationsSliderLabel)); + metrics.sliderLabel = + std::max(metrics.sliderLabel, UiMeasureTextWidth(m_fonts->uiSmall, kWeakPeakLevelLabel)); + metrics.sliderLabel = + std::max(metrics.sliderLabel, + UiMeasureTextWidth(m_fonts->uiSmall, kWeakPeakIterationsSliderLabel)); + // ⚠️ 数值列取 `100000`(6 字符):它同时是绘制侧的**裁剪宽度**,因此必须真的量出来, + // 不能写常数(换字体/换 DPI 就会截断)。`-400%` 只是宽度预算里的保守项,量出来的 + // 结果不会超过 `100000`,两者取大即可覆盖两个用途。 + metrics.valueColumn = std::max(UiMeasureTextWidth(m_fonts->monoSmall, L"100000"), + UiMeasureTextWidth(m_fonts->monoSmall, L"-400%")); + // 「重采样」四行的标签列(「最大数据间隔」比均值/弱峰的标签更宽)。 + metrics.resampleLabel = + std::max(UiMeasureTextWidth(m_fonts->uiSmall, kResampleMaxIntervalLabel), + UiMeasureTextWidth(m_fonts->uiSmall, kResampleMinIntervalLabel)); + metrics.resampleLabel = + std::max(metrics.resampleLabel, + UiMeasureTextWidth(m_fonts->uiSmall, kResampleIntervalLabel)); + metrics.resampleLabel = + std::max(metrics.resampleLabel, + UiMeasureTextWidth(m_fonts->uiSmall, kResampleFrequencyLabel)); + + // ---- 逐信号部分 ---- + // 统计列只在「边沿计数」表格与「中心轴」里出现;其余面板不必查边沿统计(省时间)。 + const bool needStats = countTable || centerAxis; + const float pendingWidth = UiMeasureTextWidth(m_fonts->monoSmall, L"统计中…"); + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + // ⚠️ 下面会解引用 `resolved.axis/style`(边沿统计),因此必须与其它绘制/命中路径 + // 用同一套空指针守卫;旧实现只判 `data`,一旦出现"有数据无样式/无时间轴"的引用 + // 就会在测量阶段空指针崩溃(本函数被绘制、命中、`QueryCursor` 调用)。 + if (resolved.data == nullptr || resolved.style == nullptr || resolved.axis == nullptr || + !resolved.visible) + { + continue; + } + ++metrics.signalCount; + // ⚠️ 名称宽度有上限(`kEdgePanelNameMaxWidth`):超长信号名不再把面板顶得很长, + // 绘制时超出部分用省略号(用户明确要求"超过某个长度就省略名称显示")。 + metrics.nameColumn = + std::max(metrics.nameColumn, + std::min(UiMeasureTextWidth(m_fonts->uiSmall, resolved.data->name), + kEdgePanelNameMaxWidth * m_scale)); + if (!needStats) + { + continue; + } + std::size_t rising = 0; + std::size_t falling = 0; + bool complete = true; + if (countTable) + { + EdgeCountsFor(*resolved.axis, *resolved.data, *resolved.style, lane.edgeValue, + EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling, + complete); + // 宽度按实际要显示的文本测量(含未算完时的"…"),否则推进过程中数字变长会被截断。 + metrics.statRising = + std::max(metrics.statRising, + std::max(UiMeasureTextWidth(m_fonts->monoSmall, + EdgeCountCellText(rising, complete)), + pendingWidth)); + metrics.statFalling = + std::max(metrics.statFalling, + UiMeasureTextWidth(m_fonts->monoSmall, + EdgeCountCellText(falling, complete))); + } + else + { + // 「中心轴边沿计数」的统计行 = 「上升沿 N / 下降沿 M」**两列**(只量已勾选的条目)。 + const Lane::EdgeCenterItem* item = lane.FindEdgeCenterItem(ref); + if (item == nullptr) + { + continue; + } + EdgeCenterCountsFor(*resolved.axis, *resolved.data, *resolved.style, item->points, + item->iterations, EdgeRangeActive(), EdgeRangeMin(), + EdgeRangeMax(), rising, falling, complete); + metrics.statRising = + std::max(metrics.statRising, + std::max(UiMeasureTextWidth(m_fonts->monoSmall, + EdgeRisingText(rising, complete)), + pendingWidth)); + metrics.statFalling = + std::max(metrics.statFalling, + UiMeasureTextWidth(m_fonts->monoSmall, + EdgeFallingText(falling, complete))); + } + } + + if (listStyle && kind == EdgePanelKind::RESAMPLE) + { + // ⚠️ 逐信号实测「最大 / 最小数据间隔」的真实文本宽度(含(频率)尾缀), + // 而不是按 `1h 1min 2s(1.23456 GHz)` 这类**典型样例**估算 —— + // 真实值(如 `16ms 182.614us(61.795 Hz)`)往往更长,估算不足就会被截断。 + for (const SignalRef& ref : lane.signals) + { + const Lane::ResampleItem* item = lane.FindResampleItem(ref); + if (item == nullptr) + { + continue; + } + metrics.resampleValueColumn = + std::max(metrics.resampleValueColumn, + UiMeasureTextWidth(m_fonts->monoSmall, + FormatIntervalWithFrequency(item->maxInterval))); + metrics.resampleValueColumn = + std::max(metrics.resampleValueColumn, + UiMeasureTextWidth(m_fonts->monoSmall, + FormatIntervalWithFrequency(item->minInterval))); + } + // 兜底下限:没有勾选任何信号时也保持一个可用的最小宽度。 + metrics.resampleValueColumn = + std::max(metrics.resampleValueColumn, + UiMeasureTextWidth(m_fonts->monoSmall, L"1h 1min 2s(1.23456 GHz)")); + } + return metrics; + } + + std::vector PlotView::PlanListPanelRows(const Lane& lane, + EdgePanelKind kind) const + { + // ⚠️ 排版类别只从唯一来源取(`PanelStyleOf`),不要在这里再写 `kind == XXX` 组合判断。 + const bool clipPanel = (kind == EdgePanelKind::CLIP); + std::vector plan; + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || !resolved.visible) + { + continue; + } + PanelRowPlan entry; + entry.ref = ref; + // 「选区截取」只有勾选状态、条目**不可展开**(用户 2026-11-03 要求)。 + if (clipPanel) + { + entry.checked = lane.ClipEnabled(ref); + plan.push_back(entry); + continue; + } + // 其余列表式面板:勾选 = 该信号有对应条目;收起 = 有参数但不展示。 + bool hasItem = false; + switch (kind) + { + case EdgePanelKind::RESAMPLE: + { + const Lane::ResampleItem* item = lane.FindResampleItem(ref); + hasItem = (item != nullptr); + entry.collapsed = (item != nullptr && item->collapsed); + break; + } + case EdgePanelKind::PEAK: + { + const Lane::WeakPeakItem* item = lane.FindWeakPeakItem(ref); + hasItem = (item != nullptr); + entry.collapsed = (item != nullptr && item->collapsed); + break; + } + case EdgePanelKind::CENTER: + { + const Lane::EdgeCenterItem* item = lane.FindEdgeCenterItem(ref); + hasItem = (item != nullptr); + entry.collapsed = (item != nullptr && item->collapsed); + break; + } + case EdgePanelKind::MEAN: + { + const Lane::MeanFilterItem* item = lane.FindMeanFilterItem(ref); + hasItem = (item != nullptr); + entry.collapsed = (item != nullptr && item->collapsed); + break; + } + case EdgePanelKind::COUNT: + case EdgePanelKind::MARK: + case EdgePanelKind::CLIP: + // 表格 / 分组面板不走本计划(`listRows` 为 false);CLIP 已在上面处理。 + break; + } + entry.checked = hasItem; + if (hasItem && !entry.collapsed) + { + // 参数行:重采样四行;中心轴 = 统计行 + 两行滑块;其余两行滑块。 + entry.sliderRows = (kind == EdgePanelKind::RESAMPLE) ? 4 : 2; + entry.hasStats = (kind == EdgePanelKind::CENTER); + } + plan.push_back(entry); + } + return plan; + } + float PlotView::EdgePanelWidth(int laneIndex, EdgePanelKind kind) const { if (m_tab == nullptr || m_fonts == nullptr || laneIndex < 0 || @@ -48,6 +252,14 @@ namespace jdi return kEdgePanelWidth * m_scale; } const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; + // 宽度 = f(列宽度量);量一次、算一次,量取结果也交给 `EdgePanelLayoutFor` 复用列几何 + //(见 `EdgePanelWidthFor` / `MeasurePanelColumns` 的文档:这里曾经有两份同形算式)。 + return EdgePanelWidthFor(lane, kind, MeasurePanelColumns(lane, kind)); + } + + float PlotView::EdgePanelWidthFor(const Lane& lane, EdgePanelKind kind, + const PanelColumnMetrics& metrics) const + { // ⚠️ 排版类别与标题都只从唯一来源取(见 `PanelStyleOf` / `PanelTitle`): // 过去这三份分类布尔量在宽度 / 布局 / 绘制里各写一遍,新增面板漏改一处就错位。 const PanelStyle panelStyle = PanelStyleOf(kind); @@ -64,81 +276,9 @@ namespace jdi 8.0f * m_scale + UiMeasureTextWidth(m_fonts->uiSmall, title) + kEdgePanelTitleHeight * m_scale; - // 数据行 = 名称与“上升沿 N / 下降沿 M”两行,取较宽者。 - // ⚠️ 名称宽度有上限(`kEdgePanelNameMaxWidth`):超长信号名不再把面板顶得很长, - // 绘制时超出部分用省略号(用户明确要求“超过某个长度就省略名称显示”)。 - // ⚠️ 统计信息按「上升沿 / 下降沿」**两列**排版(列起点固定)→ 多条信号时文字垂直对齐。 - float nameColumnWidth = 0.0f; - float statRisingWidth = 0.0f; - float statFallingWidth = 0.0f; - for (const SignalRef& ref : lane.signals) - { - const ResolvedSignal resolved = ResolveSignal(ref); - // ⚠️ 下面会解引用 `resolved.axis/style`(边沿统计),因此必须与其它绘制/命中路径 - // 用同一套空指针守卫;旧实现只判 `data`,一旦出现"有数据无样式/无时间轴"的引用 - // 就会在测量阶段空指针崩溃(`EdgePanelWidth` 被绘制、命中、`QueryCursor` 调用)。 - if (resolved.data == nullptr || resolved.style == nullptr || resolved.axis == nullptr || - !resolved.visible) - { - continue; - } - const float nameWidth = - std::min(UiMeasureTextWidth(m_fonts->uiSmall, resolved.data->name), - kEdgePanelNameMaxWidth * m_scale); - nameColumnWidth = std::max(nameColumnWidth, nameWidth); - if (listStyle) - { - // 条目 = **信号色叠底条目栏**(三角 + 名称 + 复选框)与参数行 - //(标签 + 轨道 + 数值 + 复位按钮),右侧还要给滚动条留位。 - const float groupWidth = kEdgePanelPad * 2.0f * m_scale + - kEdgePanelDisclosureWidth * m_scale + 6.0f * m_scale + - nameWidth + 6.0f * m_scale + - kEdgePanelMeanCheckSize * m_scale + - (kFreeMarkerScrollWidth + 4.0f) * m_scale; - width = std::max(width, groupWidth); - } - if (!countTable && !centerAxis) - { - continue; // 只有「边沿计数」表格 / 「中心轴」统计行需要量计数列宽 - } - std::size_t rising = 0; - std::size_t falling = 0; - bool complete = true; - if (countTable) - { - EdgeCountsFor(*resolved.axis, *resolved.data, *resolved.style, lane.edgeValue, - EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling, complete); - // 宽度按实际要显示的文本测量(含未算完时的“…”),否则推进过程中数字变长会被截断。 - const float pendingWidth = UiMeasureTextWidth(m_fonts->monoSmall, L"统计中…"); - statRisingWidth = std::max( - statRisingWidth, - std::max(UiMeasureTextWidth(m_fonts->monoSmall, EdgeCountCellText(rising, complete)), - pendingWidth)); - statFallingWidth = - std::max(statFallingWidth, - UiMeasureTextWidth(m_fonts->monoSmall, EdgeCountCellText(falling, complete))); - } - else - { - // 「中心轴边沿计数」的统计行 = 「上升沿 N / 下降沿 M」**两列**(只量已勾选的条目)。 - const Lane::EdgeCenterItem* item = lane.FindEdgeCenterItem(ref); - if (item == nullptr) - { - continue; - } - EdgeCenterCountsFor(*resolved.axis, *resolved.data, *resolved.style, item->points, - item->iterations, EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), - rising, falling, complete); - const float pendingWidth = UiMeasureTextWidth(m_fonts->monoSmall, L"统计中…"); - statRisingWidth = std::max( - statRisingWidth, - std::max(UiMeasureTextWidth(m_fonts->monoSmall, EdgeRisingText(rising, complete)), - pendingWidth)); - statFallingWidth = std::max( - statFallingWidth, - UiMeasureTextWidth(m_fonts->monoSmall, EdgeFallingText(falling, complete))); - } - } + // 数据行 / 条目栏 / 参数行 / 统计列的宽度一律来自 `metrics`(唯一来源): + // ⚠️ 这里**不再**自己写 `UiMeasureTextWidth` 链 —— 历史上宽度算式与列几何各有一份, + // 改一处漏一处就会出现"面板变宽而列不动"(文字压到轨道上)或反之(右侧一大片空白)。 if (countTable) { // 「边沿计数」= **表格**(信号名 / 上升沿计数 / 下降沿计数): @@ -147,72 +287,46 @@ namespace jdi UiMeasureTextWidth(m_fonts->uiSmall, EdgeCountTableHeader(1)); const float headerFalling = UiMeasureTextWidth(m_fonts->uiSmall, EdgeCountTableHeader(2)); - const float risingColumn = std::max(headerRising, statRisingWidth) + 10.0f * m_scale; - const float fallingColumn = std::max(headerFalling, statFallingWidth) + 10.0f * m_scale; + const float risingColumn = std::max(headerRising, metrics.statRising) + 10.0f * m_scale; + const float fallingColumn = std::max(headerFalling, metrics.statFalling) + 10.0f * m_scale; const float gap = kEdgePanelStatColumnGap * m_scale; - width = std::max(width, kEdgePanelPad * 2.0f * m_scale + - nameColumnWidth + gap + risingColumn + gap + fallingColumn + + width = std::max(width, kEdgePanelPad * 2.0f * m_scale + metrics.nameColumn + gap + + risingColumn + gap + fallingColumn + (kFreeMarkerScrollWidth + 4.0f) * m_scale); } - else if (centerAxis && (statRisingWidth > 0.0f || statFallingWidth > 0.0f)) + else if (centerAxis && (metrics.statRising > 0.0f || metrics.statFalling > 0.0f)) { // 「中心轴」的统计行 = 两列**等宽**(列起点固定 ⇒ 多条信号严格垂直对齐)。 - const float maxColumn = std::max(statRisingWidth, statFallingWidth); + const float maxColumn = std::max(metrics.statRising, metrics.statFalling); const float countsWidth = maxColumn * 2.0f + kEdgePanelStatColumnGap * m_scale; width = std::max(width, kEdgePanelPad * 2.0f * m_scale + countsWidth); } if (listStyle) { + // 条目栏 = 「三角 + 名称 + 复选框」+ 右侧滚动条留位。 + // 名称列取的是所有可见信号的最大值 ⇒ 用 `metrics.nameColumn` 一次算完 + //(条目栏宽度对名称是单调递增的,所以"逐信号取 max"与"用最大名称"等价)。 + width = std::max(width, kEdgePanelPad * 2.0f * m_scale + + kEdgePanelDisclosureWidth * m_scale + 6.0f * m_scale + + metrics.nameColumn + 6.0f * m_scale + + kEdgePanelMeanCheckSize * m_scale + + (kFreeMarkerScrollWidth + 4.0f) * m_scale); // 两行滑块 = 「标签列 + 轨道(最小宽度)+ 数值列 + 复位按钮」(两行共用同一列宽), // 右侧再给滚动条留位(上百个信号时内容必然超高)。 - const float labelColumn = - std::max(UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterPointsSliderLabel), - UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterIterationsSliderLabel)); - const float weakPeakLabelColumn = - std::max(UiMeasureTextWidth(m_fonts->uiSmall, kWeakPeakLevelLabel), - UiMeasureTextWidth(m_fonts->uiSmall, kWeakPeakIterationsSliderLabel)); - const float valueColumn = - std::max(UiMeasureTextWidth(m_fonts->monoSmall, L"100000"), - UiMeasureTextWidth(m_fonts->monoSmall, L"-400%")); - const float sliderRowWidth = std::max(labelColumn, weakPeakLabelColumn) + 6.0f * m_scale + - kWeakPeakTrackMinWidth * m_scale + 6.0f * m_scale + valueColumn + - kWeakPeakResetGap * m_scale + kWeakPeakResetButtonSize * m_scale; + const float sliderRowWidth = + metrics.sliderLabel + 6.0f * m_scale + kWeakPeakTrackMinWidth * m_scale + + 6.0f * m_scale + metrics.valueColumn + kWeakPeakResetGap * m_scale + + kWeakPeakResetButtonSize * m_scale; width = std::max(width, kEdgePanelPad * 2.0f * m_scale + sliderRowWidth + (kFreeMarkerScrollWidth + 4.0f) * m_scale); if (resamplePanel) { // 「重采样」四行 = 「标签列 + 数值列」(两行统计 + 两行输入框)。 // 标签取四者中最宽的一个;数值列必须按**真实会显示的文本**量宽 - // (用户 2026-11-03 要求:统计信息不能被省略成 `…`)。 - const float resampleLabelColumn = - std::max(std::max(UiMeasureTextWidth(m_fonts->uiSmall, kResampleMaxIntervalLabel), - UiMeasureTextWidth(m_fonts->uiSmall, kResampleMinIntervalLabel)), - std::max(UiMeasureTextWidth(m_fonts->uiSmall, kResampleIntervalLabel), - UiMeasureTextWidth(m_fonts->uiSmall, kResampleFrequencyLabel))); - // ⚠️ 逐信号实测「最大 / 最小数据间隔」的真实文本宽度(含(频率)尾缀), - // 而不是按 `1h 1min 2s(1.23456 GHz)` 这类**典型样例**估算 —— - // 真实值(如 `16ms 182.614us(61.795 Hz)`)往往更长,估算不足就会被截断。 - float resampleValueColumn = 0.0f; - for (const SignalRef& ref : lane.signals) - { - const Lane::ResampleItem* item = lane.FindResampleItem(ref); - if (item == nullptr) - { - continue; - } - resampleValueColumn = std::max( - resampleValueColumn, - UiMeasureTextWidth(m_fonts->monoSmall, FormatIntervalWithFrequency(item->maxInterval))); - resampleValueColumn = std::max( - resampleValueColumn, - UiMeasureTextWidth(m_fonts->monoSmall, FormatIntervalWithFrequency(item->minInterval))); - } - // 兜底下限:没有勾选任何信号时也保持一个可用的最小宽度。 - resampleValueColumn = std::max(resampleValueColumn, - UiMeasureTextWidth(m_fonts->monoSmall, L"1h 1min 2s(1.23456 GHz)")); - width = std::max(width, kEdgePanelPad * 2.0f * m_scale + resampleLabelColumn + - 8.0f * m_scale + resampleValueColumn + + //(用户 2026-11-03 要求:统计信息不能被省略成 `…`)。 + width = std::max(width, kEdgePanelPad * 2.0f * m_scale + metrics.resampleLabel + + 8.0f * m_scale + metrics.resampleValueColumn + (kFreeMarkerScrollWidth + 4.0f) * m_scale); } } @@ -344,9 +458,9 @@ namespace jdi } const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; // ⚠️ 排版类别只从唯一来源取(`PanelStyleOf`)—— 过去宽度 / 布局 / 绘制各写一份。 + // ⚠️ 「中心轴 / 弱峰」这两个种类判断在本函数里已**不再需要**:行高与行几何都走 + // `PlanListPanelRows`(它内部按 kind 派发),统计行由 `PanelRowPlan::hasStats` 表达。 const PanelStyle panelStyle = PanelStyleOf(kind); - const bool centerAxis = (kind == EdgePanelKind::CENTER); - const bool weakPeak = (kind == EdgePanelKind::PEAK); const bool freeMarker = (kind == EdgePanelKind::MARK); const bool resamplePanel = (kind == EdgePanelKind::RESAMPLE); const bool clipPanel = (kind == EdgePanelKind::CLIP); @@ -382,7 +496,10 @@ namespace jdi } layout.signalCount = rowCount; - const float width = EdgePanelWidth(laneIndex, kind); + // ⚠️ 列宽度量只取一次:宽算式用它,列几何(表格列 / 滑块轨道)也用它 ⇒ + // 宽度与列边界**不可能不一致**(旧结构里两份算式各量一遍,改一处漏一处就错位)。 + const PanelColumnMetrics metrics = MeasurePanelColumns(lane, kind); + const float width = EdgePanelWidthFor(lane, kind, metrics); const float titleHeight = kEdgePanelTitleHeight * m_scale; const float pad = kEdgePanelPad * m_scale; // 「中心轴边沿计数」「均值滤波」「弱峰调整」共用同一套**可滚动列表**排布, @@ -404,6 +521,15 @@ namespace jdi : (hasButton ? (buttonHeight + pad) : 0.0f); const float bottomPad = pad; + // 逐信号行计划:**行高累加与行几何的唯一来源**(见 `PanelRowPlan` 的文档)。 + // 列表式面板才需要;表格(`countTable`)与分组(`freeMarker`)走各自的路径。 + // 「选区截取」未启用双光标时整个计划作废(面板只显示一行提示)。 + std::vector rowPlan; + if (listStyle && !(clipPanel && !clipReady)) + { + rowPlan = PlanListPanelRows(lane, kind); + } + float meanContentHeight = 0.0f; float height = 0.0f; float tableHeaderHeight = 0.0f; @@ -425,75 +551,36 @@ namespace jdi } else if (listStyle) { - // 「均值滤波 / 中心轴边沿计数 / 弱峰调整」:内容区 = 逐信号的**信号色叠底条目栏** - //(内容总是显示,用于勾选/取消)+(勾选且未收起时)参数行。 - // 「中心轴」的参数行 = 一行「上升沿 / 下降沿」统计 + 两行滑块; - // 「均值滤波」与「弱峰调整」= 两行滑块。 - // 内容超高时出现滚动条;底部「构建为新信号」按钮固定不滚动。 + // 「均值滤波 / 中心轴边沿计数 / 弱峰调整 / 重采样 / 选区截取」:内容区 = 逐信号的 + // **信号色叠底条目栏**(内容总是显示,用于勾选/取消)+(勾选且未收起时)参数行。 + // 内容超高时出现滚动条;底部按钮固定不滚动。 + // + // ⚠️⚠️ 行高与下面的行几何**同源**:两者都读 `rowPlan`(见 `PanelRowPlan` 的文档)。 + // 旧实现是两趟独立遍历、各自判定"这个信号展开几行",只要有一处写错就会出现 + // "内容区高度与实际画出来的行不一致"(底部留白 / 最后一行被裁 / 明明没内容却能滚动)。 const float groupRow = kEdgePanelMeanRowHeight * m_scale; const float sliderRow = kWeakPeakSliderRowHeight * m_scale; - // ⚠️ 「选区截取」在**未启用双光标**时没有任何可操作内容(只有一行提示文字), - // 因此不按信号数累加行高、也不套用「至少 3 行」的下限 - // (用户 2026-11-03 报告:未启用双光标时仍显示信号条目、且能触发滚动)。 float content = 0.0f; if (clipPanel && !clipReady) { - // 提示行固定占一行高度(绘制时在内容区居中显示)。 - content = kEdgePanelMeanRowHeight * m_scale; + // ⚠️ 「选区截取」在**未启用双光标**时没有任何可操作内容(只有一行提示文字), + // 因此不按信号数累加行高、也不套用「至少 3 行」的下限 + // (用户 2026-11-03 报告:未启用双光标时仍显示信号条目、且能触发滚动)。 + // 提示行固定占一行高度(绘制时在内容区居中显示)。 + content = groupRow; } else { - for (const SignalRef& ref : lane.signals) - { - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data == nullptr || !resolved.visible) + for (const PanelRowPlan& plan : rowPlan) { - continue; - } - content += groupRow; - if (clipPanel) - { - continue; // 「选区截取」的条目**不可展开**,每条只占一行 - } - bool expanded = false; - if (resamplePanel) - { - const Lane::ResampleItem* item = lane.FindResampleItem(ref); - expanded = (item != nullptr && !item->collapsed); - } - else if (weakPeak) - { - const Lane::WeakPeakItem* item = lane.FindWeakPeakItem(ref); - expanded = (item != nullptr && !item->collapsed); - } - else if (centerAxis) - { - const Lane::EdgeCenterItem* item = lane.FindEdgeCenterItem(ref); - expanded = (item != nullptr && !item->collapsed); - } - else - { - const Lane::MeanFilterItem* item = lane.FindMeanFilterItem(ref); - expanded = (item != nullptr && !item->collapsed); - } - if (expanded) - { - if (resamplePanel) + content += groupRow; + if (plan.hasStats) { - // 四行:最大间隔 / 最小间隔 / 重采样间隔 / 重采样频率。 - content += sliderRow * 4.0f; - } - else - { - content += sliderRow * 2.0f; - if (centerAxis) - { - content += groupRow; // 统计行(上升沿 / 下降沿) - } + content += groupRow; // 统计行(上升沿 / 下降沿) } + content += sliderRow * static_cast(plan.sliderRows); } } - } meanContentHeight = content; const float budget = m_plotRect.Height - (titleHeight + pad + bottomPad + footerHeight); @@ -802,28 +889,17 @@ namespace jdi layout.scrollTrack = scrollMetrics.track; layout.scrollThumb = scrollMetrics.thumb; - // 两行滑块的列宽(与 `EdgePanelWidth` 同一组算式 ⇒ 布局与宽度不会漂移)。 - const float labelColumn = - std::max(std::max(UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterPointsSliderLabel), - UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterIterationsSliderLabel)), - std::max(UiMeasureTextWidth(m_fonts->uiSmall, kWeakPeakLevelLabel), - UiMeasureTextWidth(m_fonts->uiSmall, kWeakPeakIterationsSliderLabel))); - const float valueColumn = - std::max(UiMeasureTextWidth(m_fonts->monoSmall, L"100000"), - UiMeasureTextWidth(m_fonts->monoSmall, L"-400%")); + // 列宽全部来自上面那份 `metrics`(与宽度算式**同一个**来源): + // 旧实现这里又写了一遍 `UiMeasureTextWidth` 链,注释还写着"与 `EdgePanelWidth` + // 同一组算式 ⇒ 不会漂移" —— 那是靠人守的约定;现在结构上只剩一份。 + const float labelColumn = metrics.sliderLabel; + const float valueColumn = metrics.valueColumn; const float resetSize = kWeakPeakResetButtonSize * m_scale; const float resetGap = kWeakPeakResetGap * m_scale; const float trackHeight = kWeakPeakTrackHeight * m_scale; const float closeSize = kEdgePanelMeanCheckSize * m_scale; - // 「重采样」四行的标签列(「最大数据间隔」比均值/弱峰的标签更宽): - // 与 `EdgePanelWidth` 用同一组实测宽度,保证字段不会压到标签上。 - const float resampleLabelColumn = - std::max(std::max(UiMeasureTextWidth(m_fonts->uiSmall, kResampleMaxIntervalLabel), - UiMeasureTextWidth(m_fonts->uiSmall, kResampleMinIntervalLabel)), - std::max(UiMeasureTextWidth(m_fonts->uiSmall, kResampleIntervalLabel), - UiMeasureTextWidth(m_fonts->uiSmall, kResampleFrequencyLabel))); - const float rowLabelColumn = resamplePanel ? resampleLabelColumn : labelColumn; - const float contentLeft = layout.content.X; + const float rowLabelColumn = resamplePanel ? metrics.resampleLabel : labelColumn; +const float contentLeft = layout.content.X; const float trackLeft = contentLeft + labelColumn + 6.0f * m_scale; const float resetLeft = contentLeft + innerWidth - resetSize; const float trackWidth = @@ -831,59 +907,20 @@ namespace jdi (resetLeft - resetGap - valueColumn - 6.0f * m_scale) - trackLeft); float rowY = layout.content.Y - scrollMetrics.scroll; - // ⚠️ 「选区截取」在**未启用双光标**时不生成任何信号行:面板只显示一句居中提示 + // ⚠️ 行几何与上面的内容高度**同源**:都读 `rowPlan`(见 `PanelRowPlan` 的文档)。 + // 「选区截取」未启用双光标时 `rowPlan` 为空 ⇒ 不生成任何行 // (用户 2026-11-03 明确要求:不应显示信号名条目、更不应能滚动)。 - const bool clipRowsVisible = !clipPanel || clipReadyHere; - for (const SignalRef& ref : lane.signals) + for (const PanelRowPlan& plan : rowPlan) { - if (!clipRowsVisible) - { - break; - } - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data == nullptr || !resolved.visible) - { - continue; - } - bool checked = false; - bool itemCollapsed = false; - if (clipPanel) - { - // 「选区截取」:只有勾选状态,条目**不可展开**(用户 2026-11-03 要求)。 - checked = lane.ClipEnabled(ref); - } - else if (resamplePanel) - { - const Lane::ResampleItem* item = lane.FindResampleItem(ref); - checked = (item != nullptr); - itemCollapsed = (item != nullptr && item->collapsed); - } - else if (weakPeak) - { - const Lane::WeakPeakItem* item = lane.FindWeakPeakItem(ref); - checked = (item != nullptr); - itemCollapsed = (item != nullptr && item->collapsed); - } - else if (centerAxis) - { - const Lane::EdgeCenterItem* item = lane.FindEdgeCenterItem(ref); - checked = (item != nullptr); - itemCollapsed = (item != nullptr && item->collapsed); - } - else - { - const Lane::MeanFilterItem* item = lane.FindMeanFilterItem(ref); - checked = (item != nullptr); - itemCollapsed = (item != nullptr && item->collapsed); - } + const SignalRef& ref = plan.ref; // 组头(信号色叠底):整行宽度,左侧三角(仅勾选时)+ 名称,右端复选框。 // ⚠️ 行的追加走 `AppendRow()`(**唯一入口**):一行的全部字段一次成型, // 不会出现"某个分支漏推一列 ⇒ 该列之后所有行读到别人的矩形"。 - // 下面的参数行 / 重采样行一律用 `layout.rows.back().xxx = …` **赋值**(同一行,天然对齐)。 - layout.AppendRow( + // 下面的参数行一律用 `row.xxx = …` **赋值**(同一行,天然对齐)。 + EdgePanelLayout::PanelRow& row = layout.AppendRow( ref, gd::RectF(contentLeft, rowY, innerWidth, groupRow), gd::RectF(contentLeft, rowY, innerWidth, groupRow), - (checked && !clipPanel) + (plan.checked && !clipPanel) ? gd::RectF(contentLeft + 3.0f * m_scale, rowY + (groupRow - kEdgePanelRowTriangleSize * m_scale) * 0.5f, kEdgePanelRowTriangleSize * m_scale, @@ -893,53 +930,53 @@ namespace jdi rowY + (groupRow - closeSize) * 0.5f, closeSize, closeSize)); rowY += groupRow; - // 参数行:只有勾选且未收起时才有几何(未勾选的行两者都是空矩形)。 - if (!checked || itemCollapsed || clipPanel) + // 参数行:只有展开时才有几何(未勾选 / 已收起的行 `sliderRows == 0`)。 + if (plan.sliderRows <= 0) { continue; } + // 「中心轴边沿计数」:参数行最上面是**同一行**的「上升沿 / 下降沿」统计。 + if (plan.hasStats) + { + row.stats = gd::RectF(contentLeft, rowY, innerWidth, groupRow); + rowY += groupRow; + } if (resamplePanel) { // 「重采样」四行:① 最大间隔(只读)② 最小间隔(只读) // ③ 重采样间隔(输入)④ 重采样频率(输入,与③联动)。 // 每行 = 左侧标签 + 右侧数值/输入框区域。 - layout.rows.back().infoMaxInterval = gd::RectF(contentLeft, rowY, innerWidth, sliderRow); + row.infoMaxInterval = gd::RectF(contentLeft, rowY, innerWidth, sliderRow); rowY += sliderRow; - layout.rows.back().infoMinInterval = gd::RectF(contentLeft, rowY, innerWidth, sliderRow); + row.infoMinInterval = gd::RectF(contentLeft, rowY, innerWidth, sliderRow); rowY += sliderRow; // 输入行的**数值矩形**靠右对齐,宽度取剩余可用宽度(绘制框与编辑框同源)。 const float fieldLeft = contentLeft + rowLabelColumn + 8.0f * m_scale; const float fieldWidth = std::max(24.0f * m_scale, innerWidth - rowLabelColumn - 8.0f * m_scale); - layout.rows.back().intervalField = gd::RectF(fieldLeft, rowY, fieldWidth, sliderRow); + row.intervalField = gd::RectF(fieldLeft, rowY, fieldWidth, sliderRow); rowY += sliderRow; - layout.rows.back().frequencyField = gd::RectF(fieldLeft, rowY, fieldWidth, sliderRow); + row.frequencyField = gd::RectF(fieldLeft, rowY, fieldWidth, sliderRow); rowY += sliderRow; continue; } - // 「中心轴边沿计数」:参数行最上面是**同一行**的「上升沿 / 下降沿」统计。 - if (centerAxis && !layout.rows.empty()) - { - layout.rows.back().stats = gd::RectF(contentLeft, rowY, innerWidth, groupRow); - rowY += groupRow; - } const gd::RectF pointsTrack(trackLeft, rowY + (sliderRow - trackHeight) * 0.5f, trackWidth, trackHeight); - layout.rows.back().levelTrack = pointsTrack; - layout.rows.back().levelValue = + row.levelTrack = pointsTrack; + row.levelValue = gd::RectF(pointsTrack.X + pointsTrack.Width + 6.0f * m_scale, rowY, valueColumn, sliderRow); - layout.rows.back().levelReset = + row.levelReset = gd::RectF(resetLeft, rowY + (sliderRow - resetSize) * 0.5f, resetSize, resetSize); rowY += sliderRow; const gd::RectF iterTrack(trackLeft, rowY + (sliderRow - trackHeight) * 0.5f, trackWidth, trackHeight); - layout.rows.back().iterTrack = iterTrack; - layout.rows.back().iterValue = + row.iterTrack = iterTrack; + row.iterValue = gd::RectF(iterTrack.X + iterTrack.Width + 6.0f * m_scale, rowY, valueColumn, sliderRow); - layout.rows.back().iterReset = + row.iterReset = gd::RectF(resetLeft, rowY + (sliderRow - resetSize) * 0.5f, resetSize, resetSize); rowY += sliderRow; } diff --git a/Documents/Code_Review_Plan.md b/Documents/Code_Review_Plan.md index cd6d0e8..aa9e7d3 100644 --- a/Documents/Code_Review_Plan.md +++ b/Documents/Code_Review_Plan.md @@ -379,4 +379,33 @@ old keys = 919, new keys = 965 含义:旧结构里"某列根本没被 push"的那些行,现在每行都拥有该字段(值为空矩形)。 **逐行逐字段的真实几何一位都没变**,7 个面板的像素哈希也逐项未变。 +--- + +## 十二、第 40 轮追加(2026-11-04):几何算式重写 + 参数行提取(§16.7 第 1 / 第 2 项) + +> 触发:用户要求「继续上一轮报告中 §16.7 的 1 和 2 建议」,并明确第 3 点(体积)暂不投入, +> 等以后功能更新产生新的冗余时再优化。 + +| 节点 | 内容 | 验收标准 | +| --- | --- | --- | +| **P36** | 宽度算式与列几何共用一份 `PanelColumnMetrics`(`MeasurePanelColumns` + `EdgePanelWidthFor`) | `panels` 7 个面板像素哈希 + 矩形**逐项不变** | +| **P37** | 行高累加与行几何共用一份 `PanelRowPlan`(`PlanListPanelRows`):两趟遍历合一 | `rowgeom` 965+7 键 0 差异;`panelscroll` 全绿 | +| **P38** | `PaintPanelListRows` 的通用参数行提取为 `PaintPanelParamRows`(5 参数) | 双基线归零;`PaintPanelListRows` 由约 300 行降到约 120 行 | + +### 12.1 本轮新增硬约束 + +1. **宽度算式与列几何只能有一份来源**:任何"这个面板的标签列 / 数值列有多宽"都从 + `PanelColumnMetrics` 取。**禁止**在 `EdgePanelLayoutFor` 里再写 `UiMeasureTextWidth` 链 + (旧代码注释写着"与 `EdgePanelWidth` 同一组算式 ⇒ 不会漂移"——那是靠人守的约定, + 改一处漏一处就是"面板变宽而列不动",表现为文字压到轨道上)。 +2. **行高与行几何只能有一份来源**:都读 `PlanListPanelRows` 的返回值(`PanelRowPlan`)。 + **禁止**再出现"第一趟只算高度、第二趟才算几何"的双遍历 —— 两趟的判定条件必须逐字一致, + 任何一处写错就表现为底部留白 / 最后一行被裁 / 没内容却能滚动。 +3. **`PanelRowPlan::sliderRows`** 用整数表达"展开几个参数行"(0/2/4), + `hasStats` 表达「中心轴」多一行统计 —— 这两个量是"高度"与"几何"共享的**唯一判定**。 +4. **参数行绘制**走 `PaintPanelParamRows(g, layout, row, kind, PanelParamState{…})`; + 逐行状态打包进 `PanelParamState`(解析结果 / 两种条目指针 / 网格下标), + 目的是把参数表压在 5 个 —— 一个个透传会让签名比函数体更难读。 + + diff --git a/Documents/Code_Review_Progress.md b/Documents/Code_Review_Progress.md index d38d626..6ca4302 100644 --- a/Documents/Code_Review_Progress.md +++ b/Documents/Code_Review_Progress.md @@ -628,4 +628,89 @@ old keys = 919, new keys = 965 | 读点数 | 旧的 243 处 `layout.rowXxx[…]` 全部消失;现在 95 处 `layout.rows[…]`(84 在命中判定、11 在绘制) | | 行数 | `PlotView.hpp` 4795;`PlotViewInteraction.cpp` 4399;`PlotViewPanelPaint.cpp` 935;`PlotViewPaint.cpp` 5534;`PlotViewPanels.cpp` 1729 | +--- + +## 第 40 轮(2026-11-04):几何算式重写 + 参数行提取(§16.7 第 1 / 第 2 项) + +> 上一轮已完成"结构合并 + 双基线",本轮把**算式**本身收敛成单一来源。 + +### P36 宽度算式与列几何共用一份度量 + +改前:`EdgePanelWidth`(决定面板宽)与 `EdgePanelLayoutFor`(决定列边界)**各自写了一份** +同名同形的 `UiMeasureTextWidth` 链(标签列 / 数值列 / 重采样列),注释写着 +"与 `EdgePanelWidth` 同一组算式 ⇒ 不会漂移"。那是靠人守的约定。 + +改后: + +```cpp +struct PanelColumnMetrics // 唯一的列宽度量 +{ + std::size_t signalCount; // 可见信号数 + float nameColumn; // 名称列(已按上限夹取) + float statRising, statFalling; // 「边沿计数」/「中心轴」统计列 + float sliderLabel, resampleLabel;// 参数行标签列(两种,互不合并) + float valueColumn; // 数值列(同时是绘制侧的裁剪宽度) + float resampleValueColumn; // 「重采样」只读统计的数值列 +}; +PanelColumnMetrics MeasurePanelColumns(const Lane&, EdgePanelKind) const; +float EdgePanelWidthFor(const Lane&, EdgePanelKind, const PanelColumnMetrics&) const; +``` + +`EdgePanelWidth(laneIndex, kind)` 退化为"量一次 + 算一次";`EdgePanelLayoutFor` 量一次后 +**同时**用于宽度与列几何。 + +⚠️ 顺手验明一处"看起来重复其实必须分开"的量:`valueColumn` 在宽度算式里按 +`max("100000", "-400%")` 量、在列几何里按 `max("100000", "1000")` 量 —— 两者结果相同 +(`100000` 是最宽的),因此可以合并;而 **`sliderLabel` 与 `resampleLabel` 不能合并** +(「重采样」标签更长,并进来会让均值/弱峰面板整体变宽 = 像素变化)。 + +### P37 行高累加与行几何共用一份行计划 + +改前:`EdgePanelLayoutFor` 里**两趟独立遍历**同一批信号 —— +第一趟只累加 `meanContentHeight`,第二趟才生成 `rows` 并累加 `rowY`。 +两趟的判定(可见性 / 勾选 / 收起 / 是否要统计行 / 参数行数)必须逐字一致。 + +改后: + +```cpp +struct PanelRowPlan { SignalRef ref; bool checked, collapsed, hasStats; int sliderRows; }; +std::vector PlanListPanelRows(const Lane&, EdgePanelKind) const; +``` + +高度 = `groupRow + (hasStats ? groupRow : 0) + sliderRows * sliderRow`; +几何按同一个 `plan.sliderRows / hasStats` 派发 ⇒ **结构上不可能不一致**。 +「选区截取」未启用双光标时整份计划作废(面板只显示一行提示),与改前行为一致。 + +### P38 参数行绘制提取 + +`PaintPanelListRows` 里的「中心轴统计行 + 两条滑块分支(均值 / 弱峰)」 +**整块提取**为 `PaintPanelParamRows(g, layout, row, kind, PanelParamState{…})`, +`paintCounts` / `paintMeanSlider` / `paintPeakSlider` 三个 lambda 与 `valueColumn` 随之迁入。 +`PaintPanelListRows` 现在只负责"条目栏(叠底 + 悬停 + 三角 + 名称 + 复选框)" + +"按 `kind` 分派到重采样/参数行"。 + +### 过程中的两次返工(记录,避免重犯) + +1. 用"整段替换"改宽度块时,锚点 `// 两行滑块` 在 `EdgePanelWidthFor` 里**也有一处** + ⇒ 误删了宽度函数尾部。改用更长的锚点(带"的列宽")后重做。 +2. 行发射循环的锚点 `float rowY = layout.content.Y - scrollMetrics.scroll;` + 在**表格分支**里先出现一次 ⇒ 误删了「边沿计数」的行发射。改为"先定位**第二个** + `else if (listStyle)`,再从那里找 `rowY`"。 + +⇒ **教训:整段替换的锚点必须唯一;同一文件里同名语句出现多次时,先定位所属区块再取偏移。** + +### 本轮验证 + +| 项 | 结果 | +| --- | --- | +| `make release` / `make debug` / `make -C Tools` | 零 error / 零 warning | +| 产物 | 782,336 → **781,824 B**(−512) | +| `UnitTests` | `checks=260 failed=0 cases=19` | +| `OffscreenChecks -all` | 7/7 PASS,`checks=133 failed=0` | +| `panels -mode compare` | 7 个面板像素哈希**逐项不变**(含 `resample=2BFC74B3D9B3239C`) | +| `rowgeom -mode compare` | 965 + 7 键 0 差异;`-data MF4-Record.MF4` 1268 + 7 键 0 差异 | +| 行数 | `PlotViewPanelPaint.cpp` 935 → 939;`PlotViewPanels.cpp` 1729 → 1766(新增两个几何函数,净增 37) | +| 样式 | 顺手把机械搬移遗留的"整个函数体多缩进 2 个 tab"修正 | + + diff --git a/Documents/Code_Review_Report.md b/Documents/Code_Review_Report.md index f031600..c543c22 100644 --- a/Documents/Code_Review_Report.md +++ b/Documents/Code_Review_Report.md @@ -752,4 +752,78 @@ old keys = 919, new keys = 965 参数表从 9 个降到 `(g, layout, row, kind, state)` 5 个。 3. 若还要压体积:`EdgePanelLayout` 已无冗余列,剩余空间主要在字符串/浮点格式化(见 §11 的 gdtoa 结论)。 +--- + +## 十七、第 40 轮:几何算式重写 + 参数行提取(§16.7 第 1 / 第 2 项) + +### 17.1 结论 + +| §16.7 建议 | 结果 | +| --- | --- | +| 1. 面板几何算式重写(`EdgePanelWidth` + 行高累加) | ✅ 两处都收敛成单一来源(`PanelColumnMetrics` / `PanelRowPlan`),双基线零差异 | +| 2. `PaintPanelListRows` 通用参数行提取 | ✅ `PaintPanelParamRows`(5 参数),函数由约 300 行降到约 120 行 | +| 3. 体积 | ⏸ 按用户指示**暂不投入**(当前没有便宜的压缩空间,等后续功能更新产生冗余时再做) | + +产物顺带 −512 B(782,336 → **781,824 B**),零 warning。 + +### 17.2 "重构"与"重写"的区别(本轮的核心方法论) + +前几轮做的是**重构**(结构变、行为不变),判据是"双基线零差异"。 +本轮做的是**算式重写**(把两份同源算式合成一份),风险更高:两份算式**本来可能已经不一致**, +合并后必然有一边要改数 —— 那就不是"行为不变"了。 + +所以先做了一次**等价性判别**: + +| 量 | 宽度算式里的算法 | 列几何里的算法 | 是否等价 | +| --- | --- | --- | --- | +| `valueColumn` | `max(mono"100000", mono"-400%")` | `max(mono"100000", mono"1000")` | ✅ 都等于 `mono"100000"` | +| `labelColumn` | `max(均值标签, 弱峰标签)` | `max(均值标签, 弱峰标签)` | ✅ 同式 | +| `resampleLabel` | 单独算(不并入上面) | 单独算 | ✅ 同式,且**不能**并入 | +| `nameColumn` | 逐信号取 max(跳过无 axis 的) | 同上(另一处循环) | ✅ 同式 | + +⇒ 只有"可以证明等价"的量才合并。`resampleLabel` 明确**不**并入 `sliderLabel`: +「重采样」标签("最大数据间隔")比均值/弱峰的长,并进去会让后两个面板整体变宽 —— 那是像素变化, +不属于等价重构。 + +### 17.3 双遍历合一:`PanelRowPlan` + +改前 `EdgePanelLayoutFor` 里对同一批信号遍历两次(先算总高、再出行几何), +两处的判定条件(可见 / 勾选 / 收起 / 统计行 / 参数行数)必须**逐字一致**。 +这种"两趟必须同步修改"的结构就是"改一次布局就出错"的另一半机制(前半是平行数组,已在 §16 解决)。 + +改后一个 `PanelRowPlan{ref, checked, collapsed, hasStats, sliderRows}` 数组同时喂给 +高度算式与几何生成,判定只写一次。 + +### 17.4 过程中的两次锚点误伤(值得记) + +1. 锚点 `// 两行滑块` 在 `EdgePanelWidthFor` 内也匹配到 ⇒ 误删宽度函数尾部; +2. 锚点 `float rowY = layout.content.Y - scrollMetrics.scroll;` 在**表格分支**先出现 ⇒ + 误删「边沿计数」的行发射。 + +两次都是编译器立刻报错(brace 不平衡 / 未声明标识符),从备份回滚后重做。 +**规则:整段替换的锚点必须唯一;同名语句出现多次时先定位所属区块(例如"第二个 +`else if (listStyle)`")再取偏移。** + +### 17.5 验证 + +| 项 | 结果 | +| --- | --- | +| `make release` / `make debug` / `make -C Tools` | 零 warning | +| 产物 | **781,824 B**(−512) | +| `UnitTests` | 260 / 19 PASS | +| `OffscreenChecks -all` | 7 检查 / 133 断言 PASS | +| `panels -mode compare` | 7 面板像素哈希逐项不变 | +| `rowgeom -mode compare` | 965+7 键 / 1268+7 键(MF4),0 差异 | + +### 17.6 下一步(按价值排序) + +1. **面板"三处穷举 switch"再收敛**:现在新增面板仍要改 `PanelStyleOf` / `PanelTitle` / + `LanePanelScrollOf` 三处;可以考虑把它们合成一张 `constexpr` 表 + (`{kind, title, style, scrollPtr}`),新增面板只加一行数据 —— 但 `PanelTitle` 需要 + `EdgeRangeSuffix()`(运行时字符串),需先解决"标题是函数"的问题。 +2. `PaintPanelMarkerGroups`(约 260 行)按"分组栏 / 表头 / 条目吸顶"再拆, + 与 §17.3 同理:先给"吸顶推挤"单独一个可测函数。 +3. 体积:等有新的冗余再说(用户已明确)。 + + diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index f1688a2..8b55bb4 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -10758,3 +10758,59 @@ brace 不平衡。用备份回滚后重做。 (`g, layout, row, kind, state`)。 3. 体积剩余空间主要在字符串/浮点格式化(gdtoa 30+ KB 不可去掉,见 §11)。 +--- + +## 2026-11-04 · 第 40 轮(`Code_Review` 分支):几何算式重写 + 参数行提取(§17.7 第 1/2 项) + +> 规则原文:`Documents/Code_Review_Report.md` §17、`Code_Review_Plan.md` §十二、 +> `Code_Review_Progress.md` 第 40 轮。 + +### 1. ✅ 宽度算式与列几何共用一份 `PanelColumnMetrics` + +```cpp +PanelColumnMetrics MeasurePanelColumns(const Lane&, EdgePanelKind) const; +float EdgePanelWidthFor(const Lane&, EdgePanelKind, const PanelColumnMetrics&) const; +``` +`EdgePanelWidth(laneIndex, kind)` = 量一次 + 算一次;`EdgePanelLayoutFor` 量一次, +宽度与列几何都读它。 +**硬规则**:`EdgePanelLayoutFor` 里**禁止**再写 `UiMeasureTextWidth` 链算列宽。 + +⚠️ 可合并 / 不可合并的判别(做这类"算式重写"时必须先做): +* `valueColumn`:宽度用 `max("100000","-400%")`、几何用 `max("100000","1000")` → **等价**,可合并; +* `sliderLabel` 与 `resampleLabel`:**不能**合并(重采样标签更长,并进会让均值/弱峰变宽)。 + +### 2. ✅ 行高累加与行几何共用一份 `PanelRowPlan` + +```cpp +struct PanelRowPlan { SignalRef ref; bool checked, collapsed, hasStats; int sliderRows; }; +std::vector PlanListPanelRows(const Lane&, EdgePanelKind) const; +``` +高度 = `groupRow + (hasStats?groupRow:0) + sliderRows*sliderRow`;几何按同一份计划派发。 +**禁止**再出现"第一趟算高度、第二趟出几何"的双遍历(两趟判定必须逐字一致 = 错误温床)。 + +### 3. ✅ 参数行绘制提取 `PaintPanelParamRows(g, layout, row, kind, PanelParamState{…})` + +`paintCounts` / `paintMeanSlider` / `paintPeakSlider` 与 `valueColumn` 随之迁入。 +`PaintPanelListRows` 现在只剩"条目栏 + 按 kind 分派"。 + +### 4. ⚠️ 整段替换的锚点必须是**唯一**的(本轮两次误伤) + +1. `// 两行滑块` 在 `EdgePanelWidthFor` 里也有一份 ⇒ 误删宽度函数尾部; +2. `float rowY = layout.content.Y - scrollMetrics.scroll;` 在**表格分支**先出现 + ⇒ 误删「边沿计数」的行发射。 +对策:先定位所属区块(如"第二个 `else if (listStyle)`")再取偏移;改前 `Copy-Item` 备份到 `Temp\`。 + +### 5. 本轮数字 + +* Release 782,336 → **781,824 B**(−512);release/debug/tools 全零 warning。 +* `UnitTests` 260/19;`OffscreenChecks -all` 7 检查 / 133 断言全 PASS。 +* `panels -mode compare` 7 面板像素哈希逐项不变;`rowgeom compare` 965+7 / 1268+7 键 0 差异。 +* `PlotViewPanelPaint.cpp` 935 → 939;`PlotViewPanels.cpp` 1729 → 1766。 + +### 6. 下一步 + +1. 三处穷举 switch(`PanelStyleOf` / `PanelTitle` / `LanePanelScrollOf`)合成一张 constexpr 表 —— + 但 `PanelTitle` 依赖运行时 `EdgeRangeSuffix()`,需先解决"标题是函数"。 +2. `PaintPanelMarkerGroups`(约 260 行)按"分组栏 / 表头 / 条目吸顶"再拆。 +3. **体积按用户指示暂停投入**(等后续功能更新产生新冗余时再做)。 +