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

+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;
}