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:
1 parent
1463f9d3f8
commit
a9f1563896
7 files changed
+1023
-599
No files matched your search
@@ -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 轮):这四行的排布(两行只读统计 +
|
||||
|
||||
Reference in new issue
Block a user