Files
Just-DataInspector/Components/PlotView/Src/PlotViewPanelPaint.cpp
T
DeepSeek V4.1 Flash 1463f9d3f8 refactor(panels): 平行数组合并为 std::vector<PanelRow>(消除错位与字段重载)
对应 Code_Review_Report §15.6 第 1 项(方案 B:有结构,无列视图)。

结构
- EdgePanelLayout 的 16 个平行 std::vector 合并为 std::vector<PanelRow> rows,
  PanelRow 带 17 个具名成员:ref / header / nameRect / nameClose / triangle / check /
  stats / levelTrack / levelValue / levelReset / iterTrack / iterValue / iterReset /
  infoMaxInterval / infoMinInterval / intervalField / frequencyField。
- 唯一新增入口:AppendRow(ref, header, nameRect, triangle, check)(返回新行引用)与
  AppendMarkerGroupRow(ref, nameRect, nameClose)(「自由标记」分组行)。
  参数行/重采样行一律 layout.rows.back().xxx = … 赋值。
- 原 int rows(可见信号数)与新容器同名 ⇒ 改名 signalCount,并注明它与 rows.size()
  在 MARK 面板里不相等(只有有固定点的信号才有行)。

消掉的两类问题
1. 某分支漏 push 一列 ⇒ 该列之后所有行读到别人的矩形(静默错位)。现在一行一次成型。
2. rowRefs 字段重载(列表式 = 行信号 / MARK = 分组信号,其余 12 列必须为空)。
   现在每行自带 ref,语义由“填哪些字段”表达;不变式简化为语义断言
   (每行必须有名称区;MARK 行不得带 header/check/stats/滑块/重采样字段)。

顺带清理
- 合并后 rowIndex < layout.rowXxx.size() 全部变成恒真,批量删除 50+ 处
  (含 15 元组长度数组、? x : gd::RectF() 三元判空、if(...){extendRow(...)} 包裹)。
- 调试框与缓存键改为逐行遍历(新增字段只需在 PanelRow 与这两处循环各加一行)。

等价性证明(rowgeom 键名刻意保持不变,故可逐键比对)
  old keys = 919, new keys = 965
  仅存在于旧 = 0;已有键值变化 = 20(全部是 ".count":0 -> N)
  仅存在于新 = 46(全部是新增列的 [NNN],值 0.00,0.00,0.00,0.00)
⇒ 逐行逐字段的真实几何零位移;7 个面板像素哈希(含 resample=2BFC74B3D9B3239C)逐项不变。

验证
- make release:零 warning;784,896 → 782,336 B(−2,560);make debug / make -C Tools 同样零 warning
- UnitTests:checks=260 failed=0 cases=19
- OffscreenChecks -all:7 检查 / 133 断言(上轮 127)全 PASS
- panels -mode compare:7 面板像素哈希逐项不变
- rowgeom -mode compare:965+7 键 0 差异;-data MF4-Record.MF4 时 1268+7 键 0 差异
- 读点:旧 243 处 layout.rowXxx[…] → 现 95 处 layout.rows[…](84 命中判定 / 11 绘制)

过程记录:清理恒真判空时曾用整段替换把「均值滤波」双滑块段误换成「弱峰」双滑块,
编译器报 brace 不平衡,用备份回滚后重做 —— 整段替换必须先精确读出上下文。

文档:Plan §十一、Progress 第 39 轮、Report §16、agent_mem 第 39 轮、Tools/README §4.1/§4.4
2026-11-04 16:18:56 +08:00

936 lines
45 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// PlotView shard -- floating panel CONTENT painting.
//
// Extracted verbatim from PaintEdgeCursor (~1600 lines) so that each panel layout
// style (count table / list rows / marker groups) lives in its own member function
// that only receives what it actually needs. The shared frame (border, title bar,
// disclosure triangle, badge, close button) and the footer buttons stay in
// PaintEdgeCursor because they are identical for every panel.
//
// Hard constraints:
// 1. Geometry comes from EdgePanelLayout fields only -- never re-derive coordinates.
// 2. Text inside the content area must use UiTextClipped(..., layout.content):
// GDI text ignores GDI+ clipping and would otherwise paint over the title bar.
// 3. Layout classification reads pvdetail::PanelStyleOf(kind) only.
#include "PlotViewInternal.hpp"
namespace jdi
{
/**
* @brief Count-table rows of the edge-count panel (fixed header + scrollable rows).
*
* Row background = the signal colour; the three columns come from
* `layout.tableColumns`, i.e. header and data share one geometry source.
*/
void PlotView::PaintPanelCountTableRows(gd::Graphics& g, const EdgePanelLayout& layout,
const Lane& tabLane)
{
// ---- 表头 ----
{
UiFillRoundRect(g, layout.tableHeader, 3.0f * m_scale, m_theme->pressed);
UiDrawRoundRect(g, layout.tableHeader, 3.0f * m_scale, m_theme->border, 1.0f);
for (int column = 0; column < 3; ++column)
{
const EdgePanelLayout::TableColumn& cell = layout.tableColumns[column];
if (cell.width <= 0.0f)
{
continue;
}
const float textLeft =
(column == 0) ? (cell.left + 4.0f * m_scale) : cell.left;
UiTextClipped(g, EdgeCountTableHeader(column), m_fonts->uiSmall,
gd::RectF(textLeft, layout.tableHeader.Y, cell.width,
layout.tableHeader.Height),
m_theme->textMuted,
(column == 0) ? TextAlign::LEFT : TextAlign::CENTER,
layout.tableHeader);
}
}
const int tableSave = g.Save();
// 行内容必须裁到内容区:滚动时行会越出可视范围。
g.SetClip(layout.content, gd::CombineModeIntersect);
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& row = layout.rows[rowIndex].header;
if (row.Y + row.Height <= layout.content.Y ||
row.Y >= layout.content.Y + layout.content.Height)
{
continue;
}
const std::uint32_t signalColor = StyleColor(*resolved.style);
const UiInk ink = UiContrastInk(signalColor);
// 行底色 = 该信号的配色(用户 2026-11-02 要求)。
UiFillRoundRect(g, row, 3.0f * m_scale, signalColor);
UiFillRoundRect(g, row, 3.0f * m_scale, WithAlpha(signalColor, 0xE8));
// 信号名(第一列):超出列宽时省略。
const gd::RectF& nameRect = layout.rows[rowIndex].nameRect;
UiTextClipped(g, UiEllipsize(resolved.data->name, m_fonts->uiSmall,
std::max(8.0f, nameRect.Width)),
m_fonts->uiSmall, nameRect, ink.text, TextAlign::LEFT,
layout.content);
// 两列计数:与表头列**同一左边界 / 同一宽度**,纵向严格对齐。
std::size_t rising = 0;
std::size_t falling = 0;
bool complete = true;
if (resolved.axis != nullptr)
{
EdgeCountsFor(*resolved.axis, *resolved.data, *resolved.style,
tabLane.edgeValue, EdgeRangeActive(), EdgeRangeMin(),
EdgeRangeMax(), rising, falling, complete);
}
for (int column = 1; column < 3; ++column)
{
const EdgePanelLayout::TableColumn& cell = layout.tableColumns[column];
if (cell.width <= 0.0f)
{
continue;
}
const std::wstring text =
EdgeCountCellText((column == 1) ? rising : falling, complete);
UiTextClipped(g, text, m_fonts->monoSmall,
gd::RectF(cell.left, row.Y, cell.width, row.Height), ink.text,
TextAlign::CENTER, layout.content);
}
}
g.Restore(tableSave);
// 内容滚动条(只在内容超高时出现;表头不参与滚动)。
PaintPanelScrollBar(g, layout, *m_theme);
}
/**
* @brief List-style panel rows: entry bar (triangle + name + check box) plus the
* expanded parameter rows.
*
* Covers MEAN / CENTER / PEAK / RESAMPLE / CLIP. Parameter-row shape is decided by
* `kind` (RESAMPLE: four read-only/input rows, CENTER: an extra statistics row, the
* rest: two slider rows).
*/
void PlotView::PaintPanelListRows(gd::Graphics& g, const EdgePanelLayout& layout,
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 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);
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);
UiTextClipped(g, EdgeFallingText(falling, complete), m_fonts->monoSmall,
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"));
// 复选框自绘(主题色在信号色叠底上看不清):用**自适应对比色**描边/填充。
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;
}
// 「中心轴边沿计数」:参数行最上面是**同一行**的「上升沿 / 下降沿」统计。
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);
}
}
g.Restore(meanSave);
// ---- 内容滚动条(只在内容超高时出现;底部按钮不参与滚动)----
// ⚠️ 必须在 `g.Restore(meanSave)` **之后**画:滚动条位于内容区右侧、在
// 内容裁剪区之外,若在裁剪内画会被整条裁掉(用户 2026-10-30 报告
// "全部启用后没有出现滚动条"的根因)。
PaintPanelScrollBar(g, layout, *m_theme);
}
/**
* @brief 「重采样」面板**单个信号的参数区**(四行)。
*
* 从 `PaintPanelListRows` 里整块提取(第 38 轮):这四行的排布(两行只读统计 +
* 两行联动输入框、标签在左数值在右)与其它面板的滑块参数行完全不同,
* 混在同一个行循环里只会让"条目栏 / 参数行 / 重采样行"三种排版互相纠缠。
* **零逻辑改动**,判据 = `rowgeom -mode compare` + `panels -mode compare` 全绿。
*/
void PlotView::PaintPanelResampleRows(gd::Graphics& g, const EdgePanelLayout& layout,
std::size_t rowIndex, std::size_t laneIndex,
const SignalRef& ref, const Lane::ResampleItem& item)
{
const std::size_t index = laneIndex;
const float labelWidth =
UiMeasureTextWidth(m_fonts->uiSmall, kResampleMaxIntervalLabel);
const auto paintInfoRow = [&](const gd::RectF& row, const wchar_t* label,
const std::wstring& text)
{
if (row.Width <= 0.0f)
{
return;
}
UiTextClipped(g, label, m_fonts->uiSmall,
gd::RectF(row.X, row.Y, std::max(10.0f, labelWidth), row.Height),
m_theme->textMuted, TextAlign::LEFT, layout.content);
// ⚠️ 统计数字**绝不省略**(用户 2026-11-03 要求):
// 面板宽度已按真实文本量宽(`EdgePanelWidth` 逐信号实测),
// 因此这里用 `TextAlign::RIGHT` + 完整矩形即可;
// 万一仍被窗口边界夹住,也宁可超出面板右缘(由内容区裁剪兜底),
// **不要**退化成 `…` 让用户看不到真实数值。
UiTextClipped(g, text, m_fonts->monoSmall,
gd::RectF(row.X + labelWidth + 8.0f * m_scale, row.Y,
std::max(10.0f, row.Width - labelWidth - 8.0f * m_scale),
row.Height),
m_theme->textStrong, TextAlign::RIGHT, layout.content);
};
const auto paintInputRow = [&](const gd::RectF& field, const wchar_t* label,
const std::wstring& text, bool editing)
{
if (field.Width <= 0.0f)
{
return;
}
// 标签与数值同高:标签从内容区左缘画到字段左缘。
UiTextClipped(
g, label, m_fonts->uiSmall,
gd::RectF(layout.content.X, field.Y,
std::max(10.0f, field.X - 8.0f * m_scale - layout.content.X),
field.Height),
m_theme->textMuted, TextAlign::LEFT, layout.content);
if (editing)
{
return; // 正在编辑:内容由 `PaintEditField` 画
}
const gd::RectF box(field.X, field.Y + 2.0f * m_scale, field.Width,
std::max(10.0f, field.Height - 4.0f * m_scale));
UiFillRoundRect(g, box, 3.0f * m_scale, m_theme->pressed);
UiDrawRoundRect(g, box, 3.0f * m_scale, m_theme->border, 1.0f);
UiTextClipped(g, text, m_fonts->monoSmall,
gd::RectF(box.X + 4.0f * m_scale, box.Y,
std::max(6.0f, box.Width - 8.0f * m_scale), box.Height),
m_theme->textStrong, TextAlign::LEFT, layout.content);
};
const bool hasInterval = (item.maxInterval > 0.0) && (item.minInterval > 0.0);
// 「最大 / 最小数据间隔」= 自适应单位的**小数**文本(绝不用科学计数法),
// 末尾追加一个括号内的「对应频率」尾缀(单位在 Hz/kHz/MHz/GHz 之间自适应)。
//
// ⚠️⚠️ 全部使用**真实值**(用户 2026-11-03 明确要求):
// * 时间间隔:`FormatDurationCompound` 会把次级单位保留 3 位小数
// ⇒ `0.003696631s` 显示为 `3ms 696.631us`(旧实现舍入到 3 位有效数字
// 得到 `3ms 700us`,把真实尾数抹掉了);
// * 频率:由**未舍入的真实间隔**换算 ⇒ `270.517 Hz`(旧实现用舍入后的
// `3.70ms` 换算得到 `270.27 Hz`,与输入框不一致)。
// 两条路径都用真实值 ⇒ 统计行与输入框天然一致。
const auto intervalWithFrequency = [](double seconds) -> std::wstring
{
std::wstring text = FormatDurationCompound(seconds);
if (std::isfinite(seconds) && seconds > 0.0)
{
text += L"(";
text += FormatFrequencyText(1.0 / seconds);
text += L")";
}
return text;
};
// ⚠️ 四行均在本行的 `PanelRow` 里,不再需要 “数组可能更短” 的判空(旧平行数组结构的遗留)。
const EdgePanelLayout::PanelRow& row = layout.rows[rowIndex];
paintInfoRow(row.infoMaxInterval, kResampleMaxIntervalLabel,
hasInterval ? intervalWithFrequency(item.maxInterval) : L"—");
paintInfoRow(row.infoMinInterval, kResampleMinIntervalLabel,
hasInterval ? intervalWithFrequency(item.minInterval) : L"—");
const double interval = (item.interval > 0.0) ? item.interval : item.minInterval;
const bool editingInterval = (m_editTarget == PlotEditTarget::RESAMPLE_INTERVAL) &&
(m_editLane == static_cast<int>(index)) && (m_editMeanRef == ref);
const bool editingFrequency = (m_editTarget == PlotEditTarget::RESAMPLE_FREQUENCY) &&
(m_editLane == static_cast<int>(index)) && (m_editMeanRef == ref);
// 非编辑态:自适应单位 + 小数(与「最大/最小数据间隔」同一套单位形式)。
// 编辑态:只显示**秒**数值(不带单位),由 `PaintEditField` 绘制。
paintInputRow(row.intervalField, kResampleIntervalLabel, FormatIntervalText(interval),
editingInterval);
// ⚠️ 频率同样用**真实间隔**换算(与时间间隔行同源)⇒ `270.517 Hz`。
const double hertz = (interval > 0.0) ? (1.0 / interval) : 0.0;
// 非编辑态:频率单位自适应(Hz/kHz/MHz/GHz);编辑态:只显示 Hz 数值。
paintInputRow(row.frequencyField, kResampleFrequencyLabel,
(hertz > 0.0) ? FormatFrequencyText(hertz) : L"—", editingFrequency);
}
/**
* @brief Marker panel content: per-signal group bars (collapsible) + table header +
* fixed-point rows, with sticky group bars.
*
* Paint order is rows -> header -> sticky group bar (the last one covers rows that
* scroll up). Rows hidden under a sticky bar are already excluded by the layout.
*/
void PlotView::PaintPanelMarkerGroups(gd::Graphics& g, const EdgePanelLayout& layout,
const Lane& tabLane, std::vector<Lane>& tabLanes,
std::size_t laneIndex) {
const std::size_t index = laneIndex;
const float groupRowHeight = kFreeMarkerNameRowHeight * m_scale;
bool markerAny = false;
for (const Lane::FreeMarkerItem& item : tabLane.freeMarkerItems)
{
markerAny = markerAny || item.ref.IsValid();
}
const int contentState = g.Save();
g.SetClip(layout.content, gd::CombineModeIntersect);
if (!markerAny)
{
// ⚠️ 未开启「自由标记点选取」时鼠标是普通鼠标,**单击不会固定**任何数据点
//(用户 2026-10-23 要求)⇒ 提示语必须说清前提,否则用户会以为 bug。
const wchar_t* emptyHint = tabLane.freeMarkerCursor
? L"在网格内单击以固定数据点"
: L"开启「自由标记点选取」后单击网格可固定数据点";
UiTextClipped(g, emptyHint, m_fonts->uiSmall,
gd::RectF(layout.content.X, layout.content.Y, layout.content.Width,
groupRowHeight),
m_theme->textMuted, TextAlign::LEFT, layout.content);
}
// ---- 分组栏 ----
// ⚠️ 抽取成 lambda:**吸顶**的分组栏必须在条目之后重画一次(否则正在向上滚的
// 条目会盖住它)。见下方“条目 → 表头 → 吸顶分组栏”的绘制顺序。
const auto paintGroupBar = [this, &g, &layout, &tabLane, index](
const EdgePanelLayout::MarkerGroup& group)
{
const ResolvedSignal resolved = ResolveSignal(group.ref);
if (resolved.data == nullptr)
{
return;
}
const std::uint32_t signalColor =
(resolved.style != nullptr) ? StyleColor(*resolved.style) : m_theme->border;
// 托底 = 信号色;文字一律用**自适应对比色**(与信号面板的组名徽标同规则),
// 因此无论信号是亮黄还是深蓝都能看清分组栏上的所有内容。
const UiInk ink = UiContrastInk(signalColor);
// 先铺一层**完全不透明**的同色,再叠 0xE8(观感不变):
// 吸顶时它要压住下面滚动的条目,半透明会让条目透出来。
UiFillRoundRect(g, group.header, 3.0f * m_scale, signalColor);
UiFillRoundRect(g, group.header, 3.0f * m_scale, WithAlpha(signalColor, 0xE8));
// 左侧三角(与信号选择区域组名同款):朝下 = 展开、朝右 = 收起。
const bool triangleHot =
(m_freeMarkerTriangleHoverLane == static_cast<int>(index) &&
m_freeMarkerTriangleHoverSignal == group.ref);
UiDrawTriangleIcon(g, group.triangle,
triangleHot ? m_theme->accentSoft : ink.text,
group.collapsed ? IconDirection::RIGHT
: IconDirection::DOWN);
// 右侧:`N 标记点` + ✕(清除该信号全部固定点)。
std::size_t markerCount = 0;
for (const Lane::FreeMarkerItem& entry : tabLane.freeMarkerItems)
{
if (entry.ref == group.ref)
{
++markerCount;
}
}
const std::wstring countText = FormatInteger(markerCount) + L" 标记点";
const float countWidth =
UiMeasureTextWidth(m_fonts->monoSmall, countText) + 2.0f * m_scale;
const float countRight = group.close.X - 5.0f * m_scale;
UiTextClipped(g, countText, m_fonts->monoSmall,
gd::RectF(countRight - countWidth, group.header.Y, countWidth,
group.header.Height),
ink.text, TextAlign::RIGHT, layout.content);
const float nameLeft = group.triangle.X + group.triangle.Width + 6.0f * m_scale;
const float nameWidth =
std::max(10.0f, countRight - countWidth - 6.0f * m_scale - nameLeft);
UiTextClipped(g, UiEllipsize(resolved.data->name, m_fonts->uiSmall, nameWidth),
m_fonts->uiSmall,
gd::RectF(nameLeft, group.header.Y, nameWidth, group.header.Height),
ink.text, TextAlign::LEFT, layout.content);
const bool closeHot = (m_freeMarkerPanelHoverLane == static_cast<int>(index) &&
m_freeMarkerPanelHoverSignal == group.ref &&
m_freeMarkerPanelHoverItem == static_cast<std::size_t>(-1));
if (closeHot)
{
UiFillRoundRect(g, group.close, 3.0f * m_scale,
WithAlpha(m_theme->danger, 0x77));
}
const float crossPad = 3.5f * m_scale;
const std::uint32_t crossColor = closeHot ? m_theme->danger : ink.text;
UiDrawLine(g, group.close.X + crossPad, group.close.Y + crossPad,
group.close.X + group.close.Width - crossPad,
group.close.Y + group.close.Height - crossPad, crossColor,
1.5f * m_scale);
UiDrawLine(g, group.close.X + group.close.Width - crossPad,
group.close.Y + crossPad, group.close.X + crossPad,
group.close.Y + group.close.Height - crossPad, crossColor,
1.5f * m_scale);
};
for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups)
{
if (!group.pinned)
{
paintGroupBar(group);
}
}
// ---- 固定点条目(六列,列起点由布局给定 ⇒ 纵向严格对齐)----
// ⚠️ 取值与各列文本来自**与视界无关的内容缓存**(`FreeMarkerContents`):
// 大量固定点时逐帧重新采样会让"拖光标 / 平移视界"明显变卡。
const std::vector<FreeMarkerContentEntry>& contents =
FreeMarkerContents(static_cast<int>(index));
// 六列的**实测宽度**:含滚出视界的条目,保证列宽稳定、文本不被省略。
// 存回 `Lane` 供下一步的布局(`EdgePanelWidth` / `EdgePanelLayoutFor`)使用。
{
float widest[6] = {0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f};
const auto measure = [this, &widest](std::size_t column, const std::wstring& text) {
if (text.empty() || column >= 6)
{
return;
}
widest[column] = std::max(widest[column],
UiMeasureTextWidth(m_fonts->monoSmall, text));
};
// 「序号」列:取本网格所有分组里最大的序号文本宽度。
std::size_t maxSerial = 0;
std::vector<std::size_t> serialOrder;
for (const SignalRef& ref : tabLane.signals)
{
SortedFreeMarkerIndices(tabLane, ref, serialOrder);
maxSerial = std::max(maxSerial, serialOrder.size());
}
if (maxSerial > 0)
{
measure(EdgePanelLayout::MARKER_COL_SERIAL, FormatInteger(maxSerial));
}
for (const FreeMarkerContentEntry& entry : contents)
{
measure(EdgePanelLayout::MARKER_COL_VALUE, entry.valueText);
measure(EdgePanelLayout::MARKER_COL_ENUM, entry.enumText);
measure(EdgePanelLayout::MARKER_COL_DELTA, entry.deltaText);
measure(EdgePanelLayout::MARKER_COL_TIME, entry.timeText);
measure(EdgePanelLayout::MARKER_COL_DT, entry.deltaTimeText);
}
std::array<float, 6>& stored =
tabLanes[index].freeMarkerColumnWidths;
for (std::size_t column = 0; column < 6; ++column)
{
// +2px 余量:`DrawTextW` 的 `DT_END_ELLIPSIS` 对"刚好装满"的文本
// 有时仍会截成省略号(亚像素/字形伸部),留一点余量可彻底避免。
const float want =
(widest[column] > 0.0f) ? (widest[column] + 2.0f * m_scale) : 0.0f;
if (std::fabs(stored[column] - want) > 0.5f)
{
stored[column] = want;
}
}
}
// 列分隔竖线:在每列右边界(加上半个列间隙)画一条淡竖线,
// 让「序号 / 标记值 / 枚举文本 / 增量 / 时间点 / 时间间隔」有明显的列分界
//(用户 2026-10-23 要求)。最后一列不画(它右边紧接行尾 ✕)。
const auto paintColumnSeparators = [this, &g, &layout](float y, float height) {
int lastVisible = -1;
for (int column = 0; column < 6; ++column)
{
if (layout.markerColumns[column].width > 0.0f)
{
lastVisible = column;
}
}
for (int column = 0; column < lastVisible; ++column)
{
const EdgePanelLayout::MarkerColumn& cell = layout.markerColumns[column];
if (cell.width <= 0.0f)
{
continue;
}
const float lineX =
cell.left + cell.width + kFreeMarkerColumnGap * m_scale * 0.5f;
UiDrawLine(g, lineX, y + 2.0f * m_scale, lineX, y + height - 2.0f * m_scale,
WithAlpha(m_theme->borderStrong, 0x6C), 1.0f);
}
};
for (const EdgePanelLayout::MarkerRow& markerRow : layout.markerRows)
{
if (markerRow.itemIndex >= contents.size())
{
continue;
}
const FreeMarkerContentEntry& entry = contents[markerRow.itemIndex];
// ---- 行的选中 / 悬停高亮(用户 2026-10-22 要求)----
const bool selected =
(tabLane.freeMarkerSelected == static_cast<int>(markerRow.itemIndex));
const bool rowHot = (m_freeMarkerRowHoverLane == static_cast<int>(index) &&
m_freeMarkerRowHoverItem == markerRow.itemIndex);
if (selected || rowHot)
{
UiFillRect(g,
gd::RectF(markerRow.rect.X, markerRow.rect.Y + 1.0f * m_scale,
markerRow.rect.Width,
markerRow.rect.Height - 2.0f * m_scale),
selected ? WithAlpha(m_theme->accent, 0x3C)
: WithAlpha(m_theme->accent, 0x1E));
}
const auto columnRect = [&](EdgePanelLayout::MarkerColumnIndex column) {
return gd::RectF(layout.markerColumns[column].left, markerRow.rect.Y,
layout.markerColumns[column].width,
markerRow.rect.Height);
};
// ⚠️ 面板里的数据列**不得省略**(用户 2026-10-21 明确要求):
// 列宽已按实测文本宽度撑开,因此这里直接整段输出,不再 `UiEllipsize`。
// ⚠️ 必须用 `UiTextClipped`(GDI 裁剪):`UiText` 走 `DrawTextW`,
// **不认 GDI+ 裁剪** ⇒ 滚动到内容区上方的条目会把文字画到标题栏上
//(用户 2026-10-22 报告的显示错误)。
const auto paintCell = [&](EdgePanelLayout::MarkerColumnIndex column,
const std::wstring& text, std::uint32_t color) {
if (layout.markerColumns[column].width <= 0.0f || text.empty())
{
return;
}
UiTextClipped(g, text, m_fonts->monoSmall, columnRect(column), color,
TextAlign::LEFT, layout.content);
};
paintCell(EdgePanelLayout::MARKER_COL_SERIAL, FormatInteger(markerRow.serial),
m_theme->textMuted);
paintCell(EdgePanelLayout::MARKER_COL_VALUE, entry.valueText,
m_theme->textStrong);
if (layout.markerHasEnumColumn)
{
paintCell(EdgePanelLayout::MARKER_COL_ENUM, entry.enumText, m_theme->text);
}
paintCell(EdgePanelLayout::MARKER_COL_DELTA, entry.deltaText,
m_theme->textMuted);
paintCell(EdgePanelLayout::MARKER_COL_TIME, entry.timeText, m_theme->textMuted);
paintCell(EdgePanelLayout::MARKER_COL_DT, entry.deltaTimeText,
m_theme->textMuted);
// 列分隔竖线(用户 2026-10-23 要求:各列之间要有明显分界)。
paintColumnSeparators(markerRow.rect.Y, markerRow.rect.Height);
// 行尾 ✕:移除该固定点(与之前一致)。
const gd::RectF& closeRect = markerRow.close;
const bool hot = (m_freeMarkerPanelHoverLane == static_cast<int>(index) &&
m_freeMarkerPanelHoverItem == markerRow.itemIndex);
UiFillRoundRect(g, closeRect, 3.0f * m_scale,
hot ? WithAlpha(m_theme->danger, 0x66) : m_theme->cardBg);
UiDrawRoundRect(g, closeRect, 3.0f * m_scale,
hot ? m_theme->danger : m_theme->border, 1.0f);
const float crossPad = 3.5f * m_scale;
const std::uint32_t crossColor = hot ? m_theme->danger : m_theme->textMuted;
UiDrawLine(g, closeRect.X + crossPad, closeRect.Y + crossPad,
closeRect.X + closeRect.Width - crossPad,
closeRect.Y + closeRect.Height - crossPad, crossColor, 1.2f * m_scale);
UiDrawLine(g, closeRect.X + closeRect.Width - crossPad, closeRect.Y + crossPad,
closeRect.X + crossPad, closeRect.Y + closeRect.Height - crossPad,
crossColor, 1.2f * m_scale);
}
// ---- 表头(每个展开分组一行,与条目共用列边界)----
// ⚠️ 必须在条目**之后**绘制:表头底衬要不透明地盖住向上滚动的条目。
for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups)
{
if (group.tableHeader.Width <= 0.0f || group.tableHeader.Height <= 0.0f)
{
continue;
}
// 不透明底衬(与面板底色 + 少量边框色调混合,观感是“一条浅灰带”)。
UiFillRect(g, group.tableHeader,
MixColor(m_theme->cardBg, m_theme->borderStrong, 0.22));
UiDrawLine(g, group.tableHeader.X, group.tableHeader.Y + group.tableHeader.Height - 1.0f,
group.tableHeader.X + group.tableHeader.Width,
group.tableHeader.Y + group.tableHeader.Height - 1.0f,
WithAlpha(m_theme->borderStrong, 0x99), 1.0f);
for (int column = 0; column < 6; ++column)
{
const EdgePanelLayout::MarkerColumn& cell =
layout.markerColumns[column];
if (cell.width <= 0.0f)
{
continue;
}
UiTextClipped(g, kFreeMarkerColumnLabels[column], m_fonts->uiSmall,
gd::RectF(cell.left, group.tableHeader.Y, cell.width,
group.tableHeader.Height),
m_theme->textMuted, TextAlign::LEFT, layout.content);
}
// 表头的列分隔竖线(与数据行同一套列边界)。
paintColumnSeparators(group.tableHeader.Y, group.tableHeader.Height);
}
// ---- 吸顶的分组栏(最后画:压住向上滚动的条目)----
for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups)
{
if (group.pinned)
{
paintGroupBar(group);
}
}
g.Restore(contentState);
// ---- 内容滚动条(只在内容超高时出现;按钮与滑块不参与滚动)----
PaintPanelScrollBar(g, layout, *m_theme);
// ---- 「标记透明度」滑动条(固定在「自由标记点选取」按钮上方)----
if (layout.opacityTrack.Width > 0.0f)
{
// 几何全部取自布局(与命中测试同源)。
const gd::RectF& panelRect = layout.panel;
const float labelGap = kEdgePanelPad * m_scale;
UiText(g, kFreeMarkerOpacityLabel, m_fonts->uiSmall,
gd::RectF(panelRect.X + labelGap, layout.opacityValue.Y,
layout.opacityTrack.X - panelRect.X - labelGap,
layout.opacityValue.Height),
m_theme->textMuted, TextAlign::LEFT);
const bool sliderActive =
(m_dragMode == PlotDragMode::MARKER_OPACITY &&
m_markerOpacityDragLane == static_cast<int>(index)) ||
UiPointInRect(layout.opacityTrack, static_cast<float>(m_mouseX),
static_cast<float>(m_mouseY));
// 外观与信号样式区的「透明度」滑动条完全相同(同一 `UiDrawSlider` 口径)。
UiDrawSlider(g, layout.opacityTrack,
static_cast<double>(tabLane.freeMarkerOpacity), m_theme->accent,
sliderActive, m_scale);
const int percent =
static_cast<int>(tabLane.freeMarkerOpacity * 100.0f + 0.5f);
UiText(g, FormatInteger(percent) + L"%", m_fonts->monoSmall,
layout.opacityValue, m_theme->textStrong, TextAlign::RIGHT);
}
}
} // namespace jdi