refactor(panels): 几何算式收敛为单一来源 + 参数行提取(§17.6 第 1/2 项)

第 1 项:宽度算式与行高累加各自合并成一份来源
- 新增 `PanelColumnMetrics` + `MeasurePanelColumns(lane, kind)`:全仓唯一的一次"逐信号量宽"。
  * `EdgePanelWidth(laneIndex, kind)` 退化为"量一次 + 算一次"(`EdgePanelWidthFor`);
  * `EdgePanelLayoutFor` 量一次后,宽度与列几何(表格列 / 滑块轨道)都读同一份度量。
  * 改前两者各写一份同名同形的 UiMeasureTextWidth 链,注释只写着"同一组算式 ⇒ 不会漂移",
    那是靠人守的约定,改一处漏一处就是"面板变宽而列不动"(文字压到轨道上)。
- 新增 `PanelRowPlan` + `PlanListPanelRows(lane, kind)`:行高累加与行几何的唯一来源。
  * 改前 `EdgePanelLayoutFor` 对同一批信号遍历两趟(先算总高、再出行几何),
    两处判定(可见/勾选/收起/统计行/参数行数)必须逐字一致 —— 这类"两趟必须同步修改"
    正是"改一次布局就出错"的另一半机制。
  * 现在一个 {ref, checked, collapsed, hasStats, sliderRows} 数组同时喂高度算式与几何生成。
- 合并前先做等价性判别:`valueColumn` 两处算法不同但结果相同(100000 最宽)⇒ 可合并;
  `sliderLabel` 与 `resampleLabel` 不可合并(重采样标签更长,并进会让均值/弱峰变宽)。

第 2 项:`PaintPanelListRows` 的通用参数行提取
- 新增 `PaintPanelParamRows(g, layout, row, kind, PanelParamState{…})`:中心轴统计行 +
  两条滑块分支(均值/弱峰),`paintCounts`/`paintMeanSlider`/`paintPeakSlider` 三个 lambda
  与 `valueColumn` 随之迁入。
- 逐行状态打包进 `PanelParamState`(解析结果 / 两种条目指针 / 网格下标)⇒ 参数表 5 个,
  而不是 9 个散参数(签名比函数体更难读)。
- `PaintPanelListRows` 由约 300 行降到约 120 行,只负责"条目栏 + 按 kind 分派"。
- 顺手把机械搬移遗留的"整个函数体多缩进 2 个 tab"修正。

过程记录(两次锚点误伤,已写入文档避免重犯)
- 锚点 `// 两行滑块` 在 `EdgePanelWidthFor` 内也匹配 ⇒ 误删宽度函数尾部;
- 锚点 `float rowY = ...` 在表格分支先出现 ⇒ 误删「边沿计数」的行发射。
两次都是编译器立刻报错,从 Temp 备份回滚后重做。规则:整段替换的锚点必须唯一,
同名语句多次出现时先定位所属区块再取偏移。

第 3 项(体积)按用户指示暂停投入。

验证(双基线均为"零差异")
- make release / make debug / make -C Tools:零 warning;产物 782,336 → 781,824 B(−512)
- UnitTests:checks=260 failed=0 cases=19
- OffscreenChecks -all:7 检查 / 133 断言全 PASS
- panels -mode compare:7 面板像素哈希逐项不变(含 resample=2BFC74B3D9B3239C)
- rowgeom -mode compare:965+7 键 0 差异;-data MF4-Record.MF4 时 1268+7 键 0 差异

文档:Plan §十二、Progress 第 40 轮、Report §17、agent_mem 第 40 轮
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-11-04 16:36:47 +08:00
1 parent 1463f9d3f8
commit a9f1563896
7 files changed
+1023 -599

No files matched your search

+139
View File
@@ -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<PanelRowPlan> 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 「自由标记」面板的**内容区**(分组栏 + 表头 + 条目 + 吸顶)。
*
+340 -336
View File
@@ -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<int>(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<double>(current - minimum) /
static_cast<double>(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<float>(m_mouseX), static_cast<float>(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<int>(index)) &&
(m_peakDragRef == row.ref);
const bool iterActive = (m_dragMode == PlotDragMode::PEAK_ITERATIONS) &&
(m_peakDragLane == static_cast<int>(index)) &&
(m_peakDragRef == row.ref);
const bool editingMax = (m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX) &&
(m_editLane == static_cast<int>(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<int>(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<double>(current - minimum) /
static_cast<double>(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<float>(m_mouseX),
static_cast<float>(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<int>(index)) &&
(m_peakDragRef == ref);
const bool iterActive = (m_dragMode == PlotDragMode::PEAK_ITERATIONS) &&
(m_peakDragLane == static_cast<int>(index)) &&
(m_peakDragRef == ref);
const bool editingMax =
(m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX) &&
(m_editLane == static_cast<int>(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<float>(m_mouseX),
static_cast<float>(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<double>(peakAmount) / (100.0 * kWeakPeakSliderScale) + 1.0) * 0.5;
const double iterRatio =
(peakMax > kWeakPeakIterationsMin)
? (static_cast<double>(peakIter - kWeakPeakIterationsMin) /
static_cast<double>(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<int>(index)) &&
(m_meanDragRef == ref) &&
(m_meanDragKind == kind);
const bool iterationsActive =
(m_dragMode == PlotDragMode::MEAN_ITERATIONS) &&
(m_meanDragLane == static_cast<int>(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<float>(m_mouseX),
static_cast<float>(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<double>(peakAmount) / (100.0 * kWeakPeakSliderScale) + 1.0) * 0.5;
const double iterRatio =
(peakMax > kWeakPeakIterationsMin)
? (static_cast<double>(peakIter - kWeakPeakIterationsMin) /
static_cast<double>(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<int>(index)) &&
(m_meanDragRef == row.ref) && (m_meanDragKind == kind);
const bool iterationsActive = (m_dragMode == PlotDragMode::MEAN_ITERATIONS) &&
(m_meanDragLane == static_cast<int>(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 轮):这四行的排布(两行只读统计 +
+300 -263
View File
@@ -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::PanelRowPlan> PlotView::PlanListPanelRows(const Lane& lane,
EdgePanelKind kind) const
{
// ⚠️ 排版类别只从唯一来源取(`PanelStyleOf`),不要在这里再写 `kind == XXX` 组合判断。
const bool clipPanel = (kind == EdgePanelKind::CLIP);
std::vector<PanelRowPlan> 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<std::size_t>(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<std::size_t>(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<PanelRowPlan> 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<float>(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;
}