Compare commits

..
2 Commits
Author SHA1 Message Date
DeepSeek V4.1 Flash 8271879ede 面板动画帧不再重绘绘图区(组名展开/收起卡顿根治)+ 均值面板两处修正 + 信号行集合自愈
1. 组名展开/收起卡顿的真因(用户报告:288 个信号同一网格、双光标/实验性功能开启时,
   点组名展开收起的动画非常卡顿,而网格里没有任何需要重绘的变化)
   - 滚动定时器给面板动画帧做的是整窗失效,WM_PAINT 里"更新区域不在绘图区内 ⇒
     m_chromeDirty = true"这条启发式把整帧判成脏 ⇒ Render 走 fullChrome 分支 ⇒
     标签栏/工具栏/绘图区全部重绘。绘图区走场景缓存很快,但**光标叠加层每帧都要重画**:
     288 个信号的取值胶囊(圆角 + GDI 文本 + 重叠避让)实测 24~46ms/帧。
   - 新增 Application::m_panelAnimationFrame:滚动定时器在只有面板动画时置位,
     WM_PAINT 命中它且没有任何其它消息要求装饰区重绘时不再把整帧判脏 ⇒ 走 panelOnly。
   - 新增 Application::m_plotPaintValid:panelOnly 帧且该标志为真时跳过 PlotView::Paint
     (面板不覆盖绘图区,上一帧像素原样留在后备缓冲区,整窗回送也不会错)。
     作废点:InvalidatePlotRect(签名多收一个 bool&,5 个调用点统一传入)、Relayout、
     EnsureBackBuffer、WM_PAINT 中"更新区域就在绘图区内"、以及"面板动画帧里绘图区的
     滚动/回到最上方动画同时也在跑"(否则会把绘图区动画冻住)。
   - panelOnly 分支画分隔条时把右边界夹到 floor(m_plotRect.X):分隔条是浮点矩形
     (457.5…462.5),GDI+ 会把最后一列按 50% 混进去;整帧重绘时绘图区随后会盖掉它,
     但跳过绘图区的帧会留下**一整列 1px 接缝**(实测 847 像素)。
   - 实测(283 信号同网格 + 双光标):动画帧 34~43ms → 1.6~2.1ms。

2. 均值滤波面板
   - **未勾选的条目栏点击完全无响应**:未勾选时点名称区域不再自动勾选、也不展开/收起,
     只能点右端复选框(点击仍被消费,不穿透)。
   - **滚动时参数滑块不再消失**:原实现按条目栏矩形判断整行可见性,条目栏滚出内容区后
     整行(含两行参数滑块)被跳过。现在按整行纵向范围(条目栏 ∪ 两行轨道/数值/复位)判定。

3. 信号列表:行集合与折叠状态自愈(用户报告:开自由标记并标记点后,点 MF4 组名,
   三角显示已展开但下属 283 条信号行一条都不出现)
   - 新增 SignalPanel::RowsSignature()(FNV 混合 分组集合/每组信号数/是否折起/是否有动画),
     BuildRows 记录它,EnsureRows 每帧 O(分组数) 校验,不一致就地重建。
     这样任何"改了折叠状态却忘记置 m_rowsDirty"的路径都不会再让行集合与折叠状态脱节。

4. 验证(全部离屏,不占鼠标/键盘/屏幕)
   uicheck 69 / lbcheck 21 / lbresid 5 / lbzorder 4 / lbcovr 7 / lbpanel 10 /
   derivecheck 16 / round15check 28 / themecheck 17 / meancheck 21 全绿;
   新增 .preflight/panelbug 14/14(外部改折叠状态→行集合自愈、动画中途改状态→位移清零、
   未勾选行点击无响应、条目栏滚出后滑块仍绘制);
   新增 .preflight/panelperf 12/12(动画帧 <16ms、且跳过绘图区后该区域像素与紧接的
   整幅重绘逐像素完全相同);
   rendercheck A–H 8 个场景哈希与基线一致;make release / make debug 零 warning
   (805,888 B)。

同步文档:Documents/agent_mem.md 追加「2026-10-30 第十八轮」,
并把第十七轮里"尚未启用 → 直接开始预览"改标注为已推翻。
2026-10-30 15:06:01 +08:00
DeepSeek V4.1 Flash 2d83b494a3 均值面板越界根因 + 网格线进入主题配色 + 条目栏悬停/整行点击 + 均值点数下限 1
1. 「元素超出浮动面板」真根因(用户 2026-10-30 报告的截图)
   - 绘制侧 PaintEdgeCursor 的 itemized 仍是 (centerAxis || meanFilter || weakPeak),
     而布局侧 EdgePanelLayoutFor 早已是 (centerAxis || weakPeak)。
     于是均值滤波面板被旧的「逐条目」分支**再画一遍**(每行名称 + 复选框,且没有任何裁切),
     滚动后那些行的坐标是负值/越界 ⇒ 直接画到标题栏上。
   - 修法:绘制侧与布局侧统一;旧分支的兜底循环跳过条件补 meanFilter。
     底部「构建为新信号」由共享的 if (hasButton) 绘制,不受影响。
   - 顺带删除「只画完全落在内容区里的行」的 fullyInside 门控:真因修掉后
     g.SetClip(layout.content) 已足够,半截行正常显示,滚动观感连续。

2. 条目栏交互(与「自由标记」分组栏一致)
   - 整行可点:复选框区 = 切换是否预览;已启用 = 展开/收起参数行;未启用 = 直接开始预览。
     命中只要求点落在 layout.content 内(不再要求整行在内容区内),因此半截行也能操作。
   - 悬停行 → 对应预览曲线高亮:新增 m_meanRowHoverLane / m_meanRowHoverRef,
     由 OnMouseMove 维护、OnMouseLeave 与 InvalidateDerivedCaches 清除;
     PaintDerivedCurve/PaintMeanFilterCurve/PaintWeakPeakCurve 新增 bool highlight,
     先画 +5px 半透明同色光晕再把线加粗,行头叠一层 accent;悬停态变化会 InvalidateSceneCache。

3. 均值点数下限 1(用户要求:不可以为 0)
   - kMeanFilterPointsMin 0 → 1;输入提交用 centerTarget ? 0 : kMeanFilterPointsMin
     (中心轴边沿计数沿用旧语义 0 = 不平滑);预览与构建请求都再夹一次下限,
     即使条目被外部写成 0,渲染/构建结果与 1 点逐位一致。

4. 网格线颜色成为主题配色的一部分
   - struct Theme 新增 laneBg(网格叠底色);NormalizePlotContrast → FinalizeTheme,
     先解 laneBg = EnsureContrast(plotBg, cardBg, 1.03),再让 gridWeak/gridStrong/
     axisLine/borderStrong 统一对 laneBg 归一(1.30 / 1.70 / 2.10 / 1.70)。
   - 四个主题各自给出显式取值:Dark 的网格线对叠底色原本只有 1.071(几乎不可见)⇒ 现 ≥1.30;
     其它三个主题同步微调,网格可见性一致。ThemeLaneBackground 返回 laneBg。

5. 行三角尺寸统一
   - 新增 kEdgePanelRowTriangleSize = 12 逻辑像素,均值滤波行与自由标记分组栏共用;
     标题栏的 kEdgePanelDisclosureWidth = 20 保持不变。

6. 均值面板滚动条与文本
   - 滚动条必须在 g.Restore(meanSave) 之后绘制(它在内容裁剪区之外,裁剪内画会被整条裁掉)。
   - 滑块标签/数值改用 UiTextClipped(..., layout.content)(GDI 文本不受 GDI+ 裁剪约束)。

验证(全部离屏)
   uicheck 69/69、lbcheck 21/21、lbresid 5/5、lbzorder 4/4、lbcovr 7/7、lbpanel 10/10、
   derivecheck 16/16、round15check 28/28、themecheck 17/17、.preflight/meancheck 17/17;
   .preflight/meanpix:滚到顶/中/底时标题栏与面板外均为 0 像素差异,内容区正常滚动;
   rendercheck:A–H 八个场景哈希与基线完全一致;
   make release 零 warning(805,376 B);make debug 零 warning。

同步文档:Documents/agent_mem.md 追加「2026-10-30 第十七轮」,
并把第十六轮里「均值点数(0…上限)」「命中要求行完全落在内容区里」两条改成现行做法。
2026-10-30 10:51:39 +08:00
14 changed files with 649 additions and 112 deletions

No files matched your search

@@ -646,6 +646,28 @@ namespace jdi
*/ */
bool m_panelDirty = false; bool m_panelDirty = false;
bool m_mouseOverPlot = false; /**< 上一次鼠标移动是否位于绘图区内。 */ bool m_mouseOverPlot = false; /**< 上一次鼠标移动是否位于绘图区内。 */
/**
* @brief 本次 `WM_PAINT` 是不是「面板动画帧」(由滚动定时器置位)。
*
* 面板动画每帧都会把**整个窗口**标脏,而 `WM_PAINT` 里"更新区域不在绘图区内 ⇒
* 置 `m_chromeDirty`"这条启发式会因此把整帧判定为脏 ⇒ 每帧都重绘标签栏/工具栏/
* **绘图区**。绘图区一帧要 12~46ms(283 个信号同网格、双光标时实测 46ms),
* 面板动画于是被拖到 43ms/帧(用户 2026-10-30 报告:组名展开/收起非常卡顿,
* 而网格里根本没有任何需要重绘的变化)。
* ✓ 定时器置位后,`WM_PAINT` 在"没有其它任何原因要求整帧脏"时**不**再置 `m_chromeDirty`。
*/
bool m_panelAnimationFrame = false;
/**
* @brief 后备缓冲区里的**绘图区像素是否仍然有效**(可跳过重绘)。
*
* 只有「面板动画帧」且该标志为真时才跳过 `PlotView::Paint`:此时面板不覆盖绘图区,
* 上一帧画好的绘图区像素原样保留在缓冲区里,回送整窗也不会错。
* 任何"绘图区需要重绘"的来源都必须把它置假(见 `InvalidatePlotRect` 的各调用点、
* `WM_PAINT` 里"更新区域就在绘图区内"的分支、以及后备缓冲区重建)。
*/
bool m_plotPaintValid = false;
}; };
} // namespace jdi } // namespace jdi
@@ -155,9 +155,12 @@ namespace jdi
* *
* @param [IN] hwnd 窗口句柄。 * @param [IN] hwnd 窗口句柄。
* @param [IN] plotRect 绘图区矩形(客户区坐标)。 * @param [IN] plotRect 绘图区矩形(客户区坐标)。
* @param [OUT] plotPaintValid `Application::m_plotPaintValid`:置假,表示后备缓冲区里的
* 绘图区像素已不可信(面板动画帧不得跳过绘图区重绘)。
*/ */
inline void InvalidatePlotRect(HWND hwnd, const gd::RectF& plotRect) inline void InvalidatePlotRect(HWND hwnd, const gd::RectF& plotRect, bool& plotPaintValid)
{ {
plotPaintValid = false;
RECT invalid = {}; RECT invalid = {};
invalid.left = static_cast<LONG>(std::floor(plotRect.X)); invalid.left = static_cast<LONG>(std::floor(plotRect.X));
invalid.top = static_cast<LONG>(std::floor(plotRect.Y)); invalid.top = static_cast<LONG>(std::floor(plotRect.Y));
+52 -8
View File
@@ -345,6 +345,8 @@ namespace jdi
// 所有控件的目标矩形都变了,装饰区域必须整体重绘一次。 // 所有控件的目标矩形都变了,装饰区域必须整体重绘一次。
m_chromeDirty = true; m_chromeDirty = true;
// 绘图区矩形也变了 ⇒ 后备缓冲区里的绘图区像素不再是本布局的内容。
m_plotPaintValid = false;
} }
void Application::EnsureBackBuffer(int width, int height) void Application::EnsureBackBuffer(int width, int height)
@@ -358,6 +360,7 @@ namespace jdi
// 新建的后备位图内容未定义,必须整帧重绘一次。 // 新建的后备位图内容未定义,必须整帧重绘一次。
m_chromeDirty = true; m_chromeDirty = true;
m_plotPaintValid = false;
HDC screenDc = GetDC(m_hwnd); HDC screenDc = GetDC(m_hwnd);
if (screenDc == nullptr) if (screenDc == nullptr)
@@ -580,11 +583,29 @@ namespace jdi
// 只有面板像素变了 ⇒ 只重画面板与分隔条(`SignalPanel::Paint` 会铺满自己的区域)。 // 只有面板像素变了 ⇒ 只重画面板与分隔条(`SignalPanel::Paint` 会铺满自己的区域)。
UiSetTextLayer(ComponentLayer(&m_panel)); UiSetTextLayer(ComponentLayer(&m_panel));
m_panel.Paint(g); m_panel.Paint(g);
UiFillRect(g, m_splitterRect, m_theme.border); // ⚠️ 分隔条的右边界必须**夹到绘图区左边界取整之前**:分隔条矩形是浮点的
//(例如 457.5…462.5),GDI+ 会把最后一列(x=462)按 50% 覆盖率混进去;
// 整帧重绘时绘图区随后会把它整列盖掉,但**面板动画帧不重绘绘图区**,
// 这一列就会残留成一条 1px 接缝(实测 847 像素、整列)。
gd::RectF splitter = m_splitterRect;
const float splitterLimit = std::floor(m_plotRect.X);
if (splitter.X + splitter.Width > splitterLimit)
{
splitter.Width = std::max(0.0f, splitterLimit - splitter.X);
}
UiFillRect(g, splitter, m_theme.border);
} }
UiSetTextLayer(ComponentLayer(&m_plot)); UiSetTextLayer(ComponentLayer(&m_plot));
m_plot.Paint(g); // ⚠️ 「面板动画帧」且绘图区像素仍然有效时**跳过绘图区重绘**:
// 面板不覆盖绘图区,上一帧的像素原样留在后备缓冲区里(随后整窗回送也不出错),
// 而绘图区一帧要 12~46ms(283 个信号同网格 + 双光标实测 46ms)——
// 这正是“组名展开/收起非常卡顿、网格里却没有任何需要重绘的变化”的真因。
if (!panelOnly || !m_plotPaintValid)
{
m_plot.Paint(g);
m_plotPaintValid = true;
}
// 弹出菜单与浮动拾色器始终绘制在最上层,保证与主界面风格一致。 // 弹出菜单与浮动拾色器始终绘制在最上层,保证与主界面风格一致。
// ⚠️ 调试:它们是不透明浮层,文本要记入更高的绘制层, // ⚠️ 调试:它们是不透明浮层,文本要记入更高的绘制层,
@@ -936,7 +957,7 @@ namespace jdi
{ {
case kMessagePlotDerivedReady: case kMessagePlotDerivedReady:
// 后台派生曲线计算完成:只失效绘图区,下一帧即可取到最新曲线(双缓冲切换)。 // 后台派生曲线计算完成:只失效绘图区,下一帧即可取到最新曲线(双缓冲切换)。
InvalidatePlotRect(hwnd, m_plotRect); InvalidatePlotRect(hwnd, m_plotRect, m_plotPaintValid);
return 0; return 0;
case kMessagePlotLowMemory: case kMessagePlotLowMemory:
// 可用内存**严重不足**(连一条预览曲线本身都装不下):绘图区已放弃本次派生计算, // 可用内存**严重不足**(连一条预览曲线本身都装不下):绘图区已放弃本次派生计算,
@@ -944,7 +965,7 @@ namespace jdi
SetStatusMessage(L"可用内存不足,已暂停派生预览曲线与相关统计的计算。\n" SetStatusMessage(L"可用内存不足,已暂停派生预览曲线与相关统计的计算。\n"
L"请关闭部分分组或信号后重试。", L"请关闭部分分组或信号后重试。",
true); true);
InvalidatePlotRect(hwnd, m_plotRect); InvalidatePlotRect(hwnd, m_plotRect, m_plotPaintValid);
return 0; return 0;
case WM_SIZE: case WM_SIZE:
{ {
@@ -983,6 +1004,9 @@ namespace jdi
// 其余情况(窗口首次显示、其它窗口遮挡后露出、DPI 变化等)必须整帧重绘。 // 其余情况(窗口首次显示、其它窗口遮挡后露出、DPI 变化等)必须整帧重绘。
RECT update = {}; RECT update = {};
const bool hasUpdate = GetUpdateRect(hwnd, &update, FALSE) != FALSE; const bool hasUpdate = GetUpdateRect(hwnd, &update, FALSE) != FALSE;
// 面板动画帧标记是一次性的(只对紧随其后的这一帧生效)。
const bool panelFrame = m_panelAnimationFrame;
m_panelAnimationFrame = false;
if (hasUpdate) if (hasUpdate)
{ {
const bool insidePlot = const bool insidePlot =
@@ -991,8 +1015,15 @@ namespace jdi
update.right <= static_cast<LONG>(std::ceil(m_plotRect.X + m_plotRect.Width)) && update.right <= static_cast<LONG>(std::ceil(m_plotRect.X + m_plotRect.Width)) &&
update.bottom <= static_cast<LONG>(std::ceil(m_plotRect.Y + m_plotRect.Height)) && update.bottom <= static_cast<LONG>(std::ceil(m_plotRect.Y + m_plotRect.Height)) &&
update.right > update.left && update.bottom > update.top; update.right > update.left && update.bottom > update.top;
if (!insidePlot) if (insidePlot)
{ {
// 本次更新的就是绘图区 ⇒ 绘图区像素不再可信,必须重绘。
m_plotPaintValid = false;
}
else if (!panelFrame || m_chromeDirty)
{
// ⚠️ `panelFrame` 只是“本次失效来自面板动画”的标志:若其它任何消息
// 已经把装饰区标脏(`m_chromeDirty`),仍按原来的规则走整帧重绘。
m_chromeDirty = true; m_chromeDirty = true;
} }
} }
@@ -1043,7 +1074,7 @@ namespace jdi
if (plotOnlyMove && !m_chromeDirty) if (plotOnlyMove && !m_chromeDirty)
{ {
// 只失效绘图区,配合 WM_PAINT 中的判断即可走局部回送快路径。 // 只失效绘图区,配合 WM_PAINT 中的判断即可走局部回送快路径。
InvalidatePlotRect(hwnd, m_plotRect); InvalidatePlotRect(hwnd, m_plotRect, m_plotPaintValid);
} }
else else
{ {
@@ -1090,7 +1121,7 @@ namespace jdi
if (plotChanged && !chromeChanged && !m_chromeDirty) if (plotChanged && !chromeChanged && !m_chromeDirty)
{ {
InvalidatePlotRect(hwnd, m_plotRect); InvalidatePlotRect(hwnd, m_plotRect, m_plotPaintValid);
} }
else if (chromeChanged || plotChanged) else if (chromeChanged || plotChanged)
{ {
@@ -1741,6 +1772,10 @@ namespace jdi
{ {
const bool panelAnimating = const bool panelAnimating =
m_panel.ScrollAnimationActive() || m_panel.GroupAnimationActive(); m_panel.ScrollAnimationActive() || m_panel.GroupAnimationActive();
// 绘图区自己的动画(网格滚动到可视区 / 「回到最上方」淡入淡出)也会改像素:
// 它跟面板动画同时跑时,本帧**不能**跳过绘图区重绘(否则绘图区动画会冻结)。
const bool plotAnimating =
m_plot.LaneScrollAnimationActive() || m_plot.BackToTopAnimationActive();
m_panel.AdvanceScrollAnimation(); m_panel.AdvanceScrollAnimation();
m_panel.AdvanceGroupAnimations(); m_panel.AdvanceGroupAnimations();
m_plot.AdvanceLaneScrollAnimation(); m_plot.AdvanceLaneScrollAnimation();
@@ -1753,11 +1788,20 @@ namespace jdi
// ⚠️ 用**推进之前**的状态判定:动画结束那一帧仍需整窗重绘, // ⚠️ 用**推进之前**的状态判定:动画结束那一帧仍需整窗重绘,
// 保证面板的最终位置被完整重绘并回送。 // 保证面板的最终位置被完整重绘并回送。
m_panelDirty = !m_chromeDirty; m_panelDirty = !m_chromeDirty;
// 标记本帧是「面板动画帧」:`WM_PAINT` 里不再把整窗失效当成
// “装饰+绘图区都脏”,否则每帧都会重绘绘图区(283 信号 + 双光标时
// 实测 46ms/帧,用户 2026-10-30 报告的卡顿真因)。
m_panelAnimationFrame = true;
if (plotAnimating)
{
// 绘图区本帧有变化 ⇒ 作废“绘图区像素仍然有效”的假定。
m_plotPaintValid = false;
}
InvalidateRect(hwnd, nullptr, FALSE); InvalidateRect(hwnd, nullptr, FALSE);
} }
else else
{ {
InvalidatePlotRect(hwnd, m_plotRect); InvalidatePlotRect(hwnd, m_plotRect, m_plotPaintValid);
} }
return 0; return 0;
} }
+20 -3
View File
@@ -2241,9 +2241,12 @@ namespace jdi
* @param [IN] lane 网格绘制信息。 * @param [IN] lane 网格绘制信息。
* @param [IN] curve 曲线数值数组(缺失处为 NaN)。 * @param [IN] curve 曲线数值数组(缺失处为 NaN)。
* @param [IN] curveBegin `curve[0]` 对应的采样下标。 * @param [IN] curveBegin `curve[0]` 对应的采样下标。
* @param [IN] highlight 是否高亮(鼠标悬停在对应的面板条目上时):
* 先画一圈半透明的"光晕"再把曲线本身画得更粗更亮,让用户一眼看出这条预览曲线对应哪个条目。
*/ */
void PaintDerivedCurve(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane, void PaintDerivedCurve(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane,
const std::vector<float>& curve, std::size_t curveBegin) const; const std::vector<float>& curve, std::size_t curveBegin,
bool highlight = false) const;
/** /**
* @brief 取得某个采样区间内的派生曲线(带缓存)。 * @brief 取得某个采样区间内的派生曲线(带缓存)。
@@ -2465,9 +2468,10 @@ namespace jdi
* @param [IN] lane 网格绘制信息。 * @param [IN] lane 网格绘制信息。
* @param [IN] points 均值点数 X(滑平半径)。 * @param [IN] points 均值点数 X(滑平半径)。
* @param [IN] iterations 迭代次数 N(至少 1)。 * @param [IN] iterations 迭代次数 N(至少 1)。
* @param [IN] highlight 是否高亮(鼠标悬停在该网格均值滤波面板的对应条目上)。
*/ */
void PaintMeanFilterCurve(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane, void PaintMeanFilterCurve(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane,
int points, int iterations) const; int points, int iterations, bool highlight = false) const;
/** /**
* @brief 绘制某条信号的弱峰调整曲线(与均值滤波曲线同一配色派生规则)。 * @brief 绘制某条信号的弱峰调整曲线(与均值滤波曲线同一配色派生规则)。
@@ -2476,9 +2480,10 @@ namespace jdi
* @param [IN] lane 网格绘制信息。 * @param [IN] lane 网格绘制信息。
* @param [IN] amountPercent 弱峰调整幅度(-400–400)。 * @param [IN] amountPercent 弱峰调整幅度(-400–400)。
* @param [IN] iterations 迭代次数 N(1–10)。 * @param [IN] iterations 迭代次数 N(1–10)。
* @param [IN] highlight 是否高亮(鼠标悬停在该网格弱峰调整面板的对应条目上)。
*/ */
void PaintWeakPeakCurve(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane, void PaintWeakPeakCurve(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane,
int amountPercent, int iterations) const; int amountPercent, int iterations, bool highlight = false) const;
/** /**
* @brief 数据点标记的位置与"越界方向"(用于决定画圆点还是贴边三角)。 * @brief 数据点标记的位置与"越界方向"(用于决定画圆点还是贴边三角)。
@@ -2862,6 +2867,18 @@ namespace jdi
/** 「弱峰调整」每行滑块右侧的复位控件是否处于悬停态(用于“进入/离开”时只提示一次)。 */ /** 「弱峰调整」每行滑块右侧的复位控件是否处于悬停态(用于“进入/离开”时只提示一次)。 */
bool m_peakResetHover = false; bool m_peakResetHover = false;
/**
* @brief 上一帧鼠标悬停在「均值滤波 / 弱峰调整」面板的哪个**条目栏**上。
*
* 用途(用户 2026-10-30 要求):悬停某条信号的条目栏时,把该信号的**预览曲线**
* 高亮出来(画一圈光晕 + 加粗),这样"列表里这一行 ↔ 网格里那条曲线"一眼对应。
*
* 与 `m_freeMarkerRowHover*` 同一套路:只在悬停状态**变化**时返回 `changed`,
* 避免每帧都请求重绘。
*/
int m_meanRowHoverLane = -1;
SignalRef m_meanRowHoverRef;
// ---------------------------------------------------------------- 自由标记 // ---------------------------------------------------------------- 自由标记
/** 最近一次解析出的「自由标记」跟随数据点(绘制与命中共用)。 */ /** 最近一次解析出的「自由标记」跟随数据点(绘制与命中共用)。 */
+17 -2
View File
@@ -384,6 +384,15 @@ namespace jdi
*/ */
inline const float kEdgePanelDisclosureWidth = 20.0f; inline const float kEdgePanelDisclosureWidth = 20.0f;
/**
* @brief 面板内**条目行 / 分组栏**左侧展开三角的边长(逻辑像素)。
*
* ⚠️ 与标题栏的 `kEdgePanelDisclosureWidth`(20)**刻意不同**:行高只有 22 逻辑像素,
* 20 的三角会把整行顶满、显得又大又笨(用户 2026-10-30 报告)。
* 「均值滤波」的条目栏与「自由标记」的分组栏共用本值(12),观感完全一致。
*/
inline const float kEdgePanelRowTriangleSize = 12.0f;
// ⚠️ 这里曾有 `kFreeMarkerHoverTolerance = 8`(旧的非光标模式“纵向贴近”容差)。 // ⚠️ 这里曾有 `kFreeMarkerHoverTolerance = 8`(旧的非光标模式“纵向贴近”容差)。
// 用户 2026-10-22 要求把自由标记的触发范围与「常规状态下触发信号曲线高亮」**统一**, // 用户 2026-10-22 要求把自由标记的触发范围与「常规状态下触发信号曲线高亮」**统一**,
// 因此现在直接复用 `kSignalHitTolerance`(6 逻辑像素)并改判“到折线的垂直距离” // 因此现在直接复用 `kSignalHitTolerance`(6 逻辑像素)并改判“到折线的垂直距离”
@@ -559,8 +568,14 @@ namespace jdi
*/ */
inline const wchar_t* const kMeanFilterPointsSliderLabel = L"均值点数"; inline const wchar_t* const kMeanFilterPointsSliderLabel = L"均值点数";
inline const wchar_t* const kMeanFilterIterationsSliderLabel = L"迭代次数"; inline const wchar_t* const kMeanFilterIterationsSliderLabel = L"迭代次数";
/** 「均值点数」下限(窗口 = 2X+1 ⇒ X=0 就是原信号)。 */ /**
inline const int kMeanFilterPointsMin = 0; * @brief 「均值点数」下限。
*
* ⚠️ **必须是 1**(用户 2026-10-30 要求):窗口 = `2X+1`,`X = 0` 等于"不做任何平滑",
* 滑块拖到最左端时预览曲线与原始波形完全重合 —— 那不像"滤波",用户会以为功能坏了。
* 下限取 1 ⇒ 最左端是 3 点均值,始终有可见的平滑效果。
*/
inline const int kMeanFilterPointsMin = 1;
/** 「均值点数」滑块的默认上限(用户可点击数值改成其它正整数)。 */ /** 「均值点数」滑块的默认上限(用户可点击数值改成其它正整数)。 */
inline const int kMeanFilterPointsDefaultMax = 50; inline const int kMeanFilterPointsDefaultMax = 50;
/** 「均值点数」上限允许的最大值(防止输入一个天文数字把滤波窗口撑爆)。 */ /** 「均值点数」上限允许的最大值(防止输入一个天文数字把滤波窗口撑爆)。 */
@@ -644,6 +644,9 @@ namespace jdi
m_hoverLegend = SignalRef(); m_hoverLegend = SignalRef();
m_dragSignal = SignalRef(); m_dragSignal = SignalRef();
m_dragTargetLane = -1; m_dragTargetLane = -1;
// 面板条目栏的悬停同样按引用保存(均值滤波 / 弱峰调整的预览曲线高亮)。
m_meanRowHoverLane = -1;
m_meanRowHoverRef = SignalRef();
InvalidateSceneCache(); InvalidateSceneCache();
} }
+87 -22
View File
@@ -699,8 +699,11 @@ namespace jdi
} }
const int maximum = isPoints ? 1000000 : 1000; const int maximum = isPoints ? 1000000 : 1000;
int value = static_cast<int>(std::lround(parsed)); int value = static_cast<int>(std::lround(parsed));
// 迭代次数至少为 1(1 次迭代等于不做滤波);均值点数可以为 0。 // 迭代次数至少为 1(1 次迭代等于不做滤波)。
value = isPoints ? std::max(0, std::min(value, maximum)) // 「均值滤波」的**均值点数下限为 1**(用户 2026-10-30 明确要求:不能为 0);
// 「中心轴边沿计数」沿用旧语义(0 = 不做平滑),因此两者下限分开取。
const int pointsMin = centerTarget ? 0 : kMeanFilterPointsMin;
value = isPoints ? std::max(pointsMin, std::min(value, maximum))
: std::max(1, std::min(value, maximum)); : std::max(1, std::min(value, maximum));
Lane::MeanFilterItem& item = centerTarget ? lane.EnsureEdgeCenterItem(m_editMeanRef) Lane::MeanFilterItem& item = centerTarget ? lane.EnsureEdgeCenterItem(m_editMeanRef)
@@ -1824,6 +1827,49 @@ namespace jdi
hoverPanelKind); hoverPanelKind);
} }
// 「均值滤波 / 弱峰调整」面板**条目栏**的悬停 → 高亮该信号的预览曲线
//(用户 2026-10-30 要求:"鼠标处于条目栏上方时,对应的预览曲线需要高亮提示")。
{
int hoverLane = -1;
SignalRef hoverRef;
if (overEdgePanel && m_tab != nullptr && panelLane >= 0 &&
(static_cast<EdgePanelKind>(hoverPanelKind) == EdgePanelKind::MEAN ||
static_cast<EdgePanelKind>(hoverPanelKind) == EdgePanelKind::PEAK))
{
const EdgePanelLayout layout =
EdgePanelLayoutFor(panelLane, static_cast<EdgePanelKind>(hoverPanelKind));
if (UiPointInRect(layout.content, static_cast<float>(x), static_cast<float>(y)))
{
for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex)
{
if (rowIndex >= layout.rowHeaders.size())
{
break;
}
const gd::RectF& headerRect = layout.rowHeaders[rowIndex];
// 半截滚出的行**也算命中**:绘制时行本来就会被裁剪成半截显示,
// 若这里要求“完整落在内容区内”,就会出现“看得见却悬停不到”的不一致。
// 命中点本身已被限定在内容区里,因此不会出现“看得见一半却点到”的幽灵热区。
if (UiPointInRect(headerRect, static_cast<float>(x), static_cast<float>(y)))
{
hoverLane = panelLane;
hoverRef = layout.rowRefs[rowIndex];
break;
}
}
}
}
if (hoverLane != m_meanRowHoverLane || !(hoverRef == m_meanRowHoverRef))
{
m_meanRowHoverLane = hoverLane;
m_meanRowHoverRef = hoverRef;
// 预览曲线在部分绘制路径里属于"静态场景"(整幅重绘会缓存它),
// 因此悬停状态一变就作废场景缓存 —— 只在"进出某一行"时发生,不是每帧。
InvalidateSceneCache();
changed = true;
}
}
// 「弱峰调整」每行滑块右侧的**复位控件**:悬停时给出一次性提示(进入时设置、离开时清除)。 // 「弱峰调整」每行滑块右侧的**复位控件**:悬停时给出一次性提示(进入时设置、离开时清除)。
// 与「系数/偏移」二级面板标题栏的还原图标同一交互语义(悬停高亮 + 手型 + 提示)。 // 与「系数/偏移」二级面板标题栏的还原图标同一交互语义(悬停高亮 + 手型 + 提示)。
bool overPeakReset = false; bool overPeakReset = false;
@@ -2216,18 +2262,8 @@ namespace jdi
{ {
break; break;
} }
// 只允许命中**完全落在内容区里**的行(部分滚出的行其矩形会与内容区 // 行可以“半截”显示(绘制时会按内容区裁剪),因此这里不再要求整行落在内容区;
// 边界重叠,放行会造出"看得见一半却能点"的幽灵热区)。 // 命中点已由上面的 `insideContent` 限定在内容区内,不会抢走底部按钮的点击。
if (rowIndex < layout.rowHeaders.size())
{
const gd::RectF& headerRect = layout.rowHeaders[rowIndex];
if (headerRect.Y < layout.content.Y - 0.5f ||
headerRect.Y + headerRect.Height >
layout.content.Y + layout.content.Height + 0.5f)
{
continue;
}
}
const bool hasParams = const bool hasParams =
rowIndex < layout.rowLevelTracks.size() && rowIndex < layout.rowLevelTracks.size() &&
layout.rowLevelTracks[rowIndex].Width > 0.0f && layout.rowLevelTracks[rowIndex].Width > 0.0f &&
@@ -2334,13 +2370,34 @@ namespace jdi
return true; return true;
} }
} }
// 组头左侧三角:切换参数行的收起 / 展开(只有勾选后才有三角)。 // 条目栏(信号色叠底那一整行):
if (rowIndex < layout.rowTriangles.size() && // * 点在右端复选框上 → 切换"是否启用该信号的预览"(与复选框同一语义,
layout.rowTriangles[rowIndex].Width > 0.0f && // 因为整行已被本函数消费,复选框自己收不到点击了);
UiPointInRect(layout.rowTriangles[rowIndex], fx, fy)) // * 已启用 → 整行切换参数行的收起 / 展开;
// * **未启用 → 什么都不做**(用户 2026-10-30 明确要求:未勾选前点击条目栏
// 既不能自动勾选、也不能展开 / 收起,只能点复选框)。
// 仍然消费这次点击(不穿透到底层的平移/选区等)。
if (rowIndex < layout.rowHeaders.size() &&
layout.rowHeaders[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowHeaders[rowIndex], fx, fy))
{ {
Lane::MeanFilterItem& item = meanLane.EnsureMeanFilterItem(ref); const bool onCheckBox =
item.collapsed = !item.collapsed; rowIndex < layout.rowChecks.size() &&
layout.rowChecks[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowChecks[rowIndex], fx, fy);
Lane::MeanFilterItem* existing = const_cast<Lane::MeanFilterItem*>(
meanLane.FindMeanFilterItem(ref));
if (onCheckBox)
{
ToggleLaneMeanFilterSignal(index, ref);
m_meanRowHoverLane = -1;
m_meanRowHoverRef = SignalRef();
}
else if (existing != nullptr)
{
// 已启用:整行切换参数行的收起 / 展开。
existing->collapsed = !existing->collapsed;
}
m_dragMode = PlotDragMode::NONE; m_dragMode = PlotDragMode::NONE;
return true; return true;
} }
@@ -2564,8 +2621,9 @@ namespace jdi
MeanFilterBuild build; MeanFilterBuild build;
build.groupIndex = ref.groupIndex; build.groupIndex = ref.groupIndex;
build.signalIndex = ref.signalIndex; build.signalIndex = ref.signalIndex;
build.points = item->points; // 与预览一致:点数下限 1、迭代次数下限 1(用户 2026-10-30 要求)。
build.iterations = item->iterations; build.points = std::max(kMeanFilterPointsMin, item->points);
build.iterations = std::max(kMeanFilterIterationsMin, item->iterations);
m_pendingMeanFilterItems.push_back(build); m_pendingMeanFilterItems.push_back(build);
} }
if (!m_pendingMeanFilterItems.empty()) if (!m_pendingMeanFilterItems.empty())
@@ -3398,6 +3456,13 @@ namespace jdi
m_freeMarkerRowHoverItem = static_cast<std::size_t>(-1); m_freeMarkerRowHoverItem = static_cast<std::size_t>(-1);
changed = true; changed = true;
} }
// 「均值滤波 / 弱峰调整」条目栏的悬停高亮同样随鼠标离开清除(否则预览曲线一直亮着)。
if (m_meanRowHoverLane != -1 || m_meanRowHoverRef.IsValid())
{
m_meanRowHoverLane = -1;
m_meanRowHoverRef = SignalRef();
changed = true;
}
if (m_cursorHover) if (m_cursorHover)
{ {
m_cursorHover = false; m_cursorHover = false;
+116 -27
View File
@@ -2063,6 +2063,9 @@ namespace jdi
} }
const int saveState = g.Save(); const int saveState = g.Save();
g.SetClip(lane.rect, gd::CombineModeIntersect); g.SetClip(lane.rect, gd::CombineModeIntersect);
// 面板条目栏的悬停高亮(用户 2026-10-30 要求):鼠标停在哪一行,就把那条**预览曲线**
// 画一圈光晕 + 加粗。`m_meanRowHoverLane/Ref` 由 `OnMouseMove` 维护。
const bool hoverActive = (m_meanRowHoverLane == laneIndex) && m_meanRowHoverRef.IsValid();
for (const SignalRef& ref : tabLane.signals) for (const SignalRef& ref : tabLane.signals)
{ {
const ResolvedSignal resolved = ResolveSignal(ref); const ResolvedSignal resolved = ResolveSignal(ref);
@@ -2070,6 +2073,7 @@ namespace jdi
{ {
continue; continue;
} }
const bool hovered = hoverActive && (ref == m_meanRowHoverRef);
if (centerOn) if (centerOn)
{ {
const Lane::EdgeCenterItem* centerItem = tabLane.FindEdgeCenterItem(ref); const Lane::EdgeCenterItem* centerItem = tabLane.FindEdgeCenterItem(ref);
@@ -2083,7 +2087,7 @@ namespace jdi
const Lane::MeanFilterItem* item = tabLane.FindMeanFilterItem(ref); const Lane::MeanFilterItem* item = tabLane.FindMeanFilterItem(ref);
if (item != nullptr) if (item != nullptr)
{ {
PaintMeanFilterCurve(g, resolved, lane, item->points, item->iterations); PaintMeanFilterCurve(g, resolved, lane, item->points, item->iterations, hovered);
} }
} }
if (weakPeakOn) if (weakPeakOn)
@@ -2097,7 +2101,7 @@ namespace jdi
const int boostIterations = std::max( const int boostIterations = std::max(
kWeakPeakIterationsMin, kWeakPeakIterationsMin,
std::min(kWeakPeakIterationsMaxLimit, item->iterations)); std::min(kWeakPeakIterationsMaxLimit, item->iterations));
PaintWeakPeakCurve(g, resolved, lane, amountPercent, boostIterations); PaintWeakPeakCurve(g, resolved, lane, amountPercent, boostIterations, hovered);
} }
} }
} }
@@ -3158,7 +3162,7 @@ namespace jdi
void PlotView::PaintDerivedCurve(gd::Graphics& g, const ResolvedSignal& resolved, void PlotView::PaintDerivedCurve(gd::Graphics& g, const ResolvedSignal& resolved,
const LaneVisual& lane, const std::vector<float>& curve, const LaneVisual& lane, const std::vector<float>& curve,
std::size_t curveBegin) const std::size_t curveBegin, bool highlight) const
{ {
if (resolved.data == nullptr || resolved.axis == nullptr || resolved.style == nullptr) if (resolved.data == nullptr || resolved.axis == nullptr || resolved.style == nullptr)
{ {
@@ -3273,6 +3277,27 @@ namespace jdi
pen.SetLineJoin(gd::LineJoinRound); pen.SetLineJoin(gd::LineJoinRound);
pen.SetStartCap(gd::LineCapRound); pen.SetStartCap(gd::LineCapRound);
pen.SetEndCap(gd::LineCapRound); pen.SetEndCap(gd::LineCapRound);
// 悬停高亮(用户 2026-10-30 要求):先沿同一批顶点画一遍**加粗的半透明光晕**,
// 再把曲线本身画粗一点。光晕垫在曲线下面 ⇒ 不遮蔽曲线本身、也不影响它的颜色。
if (highlight)
{
gd::Pen halo(ToGdiColor(WithAlpha(derived, 0x55u)),
std::max(1.0f, resolved.style->lineWidth) * m_scale + 5.0f * m_scale);
halo.SetLineJoin(gd::LineJoinRound);
halo.SetStartCap(gd::LineCapRound);
halo.SetEndCap(gd::LineCapRound);
for (std::size_t k = 0; k < breaks.size(); ++k)
{
const std::size_t start = breaks[k];
const std::size_t stop = (k + 1 < breaks.size()) ? breaks[k + 1] : points.size();
if (stop <= start + 1)
{
continue;
}
g.DrawLines(&halo, points.data() + start, static_cast<INT>(stop - start));
}
pen.SetWidth(std::max(1.0f, resolved.style->lineWidth) * m_scale + 1.6f * m_scale);
}
for (std::size_t k = 0; k < breaks.size(); ++k) for (std::size_t k = 0; k < breaks.size(); ++k)
{ {
const std::size_t start = breaks[k]; const std::size_t start = breaks[k];
@@ -3287,7 +3312,8 @@ namespace jdi
} }
void PlotView::PaintMeanFilterCurve(gd::Graphics& g, const ResolvedSignal& resolved, void PlotView::PaintMeanFilterCurve(gd::Graphics& g, const ResolvedSignal& resolved,
const LaneVisual& lane, int points, int iterations) const const LaneVisual& lane, int points, int iterations,
bool highlight) const
{ {
if (resolved.data == nullptr || resolved.axis == nullptr || resolved.style == nullptr) if (resolved.data == nullptr || resolved.axis == nullptr || resolved.style == nullptr)
{ {
@@ -3305,6 +3331,10 @@ namespace jdi
// **逐位一致**(用户明确要求"预览显示效果要和构建为新信号一致")。 // **逐位一致**(用户明确要求"预览显示效果要和构建为新信号一致")。
// 整段窗口超过异步门槛时由后台线程计算,绘制线程本帧用已算好的旧曲线顶替, // 整段窗口超过异步门槛时由后台线程计算,绘制线程本帧用已算好的旧曲线顶替,
// 因此不会阻塞交互;小信号同步计算。 // 因此不会阻塞交互;小信号同步计算。
// ⚠️ 「均值点数」的下限是 `kMeanFilterPointsMin`(=1,用户 2026-10-30 明确要求不能为 0):
// 即使条目里的值被外部写成了 0/负数,预览也不能按“不平滑”画出来。
points = std::max(kMeanFilterPointsMin, points);
iterations = std::max(kMeanFilterIterationsMin, iterations);
std::size_t spanBegin = 0; std::size_t spanBegin = 0;
const std::vector<float>* curve = CurveSpanFor(DerivedCurveKind::MEAN_FILTER, *resolved.data, const std::vector<float>* curve = CurveSpanFor(DerivedCurveKind::MEAN_FILTER, *resolved.data,
points, iterations, 0, 0, count, spanBegin, true); points, iterations, 0, 0, count, spanBegin, true);
@@ -3312,11 +3342,12 @@ namespace jdi
{ {
return; return;
} }
PaintDerivedCurve(g, resolved, lane, *curve, spanBegin); PaintDerivedCurve(g, resolved, lane, *curve, spanBegin, highlight);
} }
void PlotView::PaintWeakPeakCurve(gd::Graphics& g, const ResolvedSignal& resolved, void PlotView::PaintWeakPeakCurve(gd::Graphics& g, const ResolvedSignal& resolved,
const LaneVisual& lane, int amountPercent, int iterations) const const LaneVisual& lane, int amountPercent, int iterations,
bool highlight) const
{ {
if (resolved.data == nullptr || resolved.axis == nullptr || resolved.style == nullptr) if (resolved.data == nullptr || resolved.axis == nullptr || resolved.style == nullptr)
{ {
@@ -4368,8 +4399,12 @@ namespace jdi
const bool meanFilter = (kind == EdgePanelKind::MEAN); const bool meanFilter = (kind == EdgePanelKind::MEAN);
const bool weakPeak = (kind == EdgePanelKind::PEAK); const bool weakPeak = (kind == EdgePanelKind::PEAK);
const bool freeMarker = (kind == EdgePanelKind::MARK); const bool freeMarker = (kind == EdgePanelKind::MARK);
// 「均值滤波」与「弱峰调整」共用「逐条目 + 底部按钮」排布。 // ⚠️ `itemized` 只表示**旧的逐条目排布**(中心轴 / 弱峰调整)。
const bool itemized = (centerAxis || meanFilter || weakPeak); // 「均值滤波」自 2026-10-30 起有自己的**可滚动分支**(`if (meanFilter)` 内),
// 若它仍被算作 `itemized`,旧分支会**再画一遍**每行的名称 + 复选框 ——
// 且旧分支**没有任何裁切**,滚动时这些元素就会画到标题栏上
//(用户 2026-10-30 报告“元素超出浮动面板”的根因)。
const bool itemized = (centerAxis || weakPeak);
const bool hasButton = (meanFilter || weakPeak); const bool hasButton = (meanFilter || weakPeak);
{ {
if (index >= count) if (index >= count)
@@ -4868,20 +4903,62 @@ namespace jdi
continue; continue;
} }
const gd::RectF& header = layout.rowHeaders[rowIndex]; const gd::RectF& header = layout.rowHeaders[rowIndex];
if (header.Y + header.Height <= layout.content.Y || // 行的**可见性**必须按“整行的纵向范围”判定,不能只看条目栏:
header.Y >= layout.content.Y + layout.content.Height) // 条目栏已滚出内容区,但它下面两行参数滑块仍可能还在可视区内
//(用户 2026-10-30 报告:此时两个滑块会直接消失 —— 显示错误)。
float rowTop = header.Y;
float rowBottom = header.Y + 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);
}
};
if (rowIndex < layout.rowLevelTracks.size())
{ {
continue; // 完全滚出可视范围 extendRow(layout.rowLevelTracks[rowIndex]);
}
if (rowIndex < layout.rowLevelValues.size())
{
extendRow(layout.rowLevelValues[rowIndex]);
}
if (rowIndex < layout.rowLevelResets.size())
{
extendRow(layout.rowLevelResets[rowIndex]);
}
if (rowIndex < layout.rowIterTracks.size())
{
extendRow(layout.rowIterTracks[rowIndex]);
}
if (rowIndex < layout.rowIterValues.size())
{
extendRow(layout.rowIterValues[rowIndex]);
}
if (rowIndex < layout.rowIterResets.size())
{
extendRow(layout.rowIterResets[rowIndex]);
}
if (rowBottom <= layout.content.Y ||
rowTop >= layout.content.Y + layout.content.Height)
{
continue; // 整行(含全部参数行)完全滚出可视范围
} }
const std::uint32_t signalColor = StyleColor(*resolved.style); const std::uint32_t signalColor = StyleColor(*resolved.style);
const UiInk ink = UiContrastInk(signalColor); const UiInk ink = UiContrastInk(signalColor);
const Lane::MeanFilterItem* item = tabLane.FindMeanFilterItem(ref); const Lane::MeanFilterItem* item = tabLane.FindMeanFilterItem(ref);
const bool checked = (item != nullptr); const bool checked = (item != nullptr);
// 叠底:先铺一层不透明同色再叠 0xE8(与「自由标记」分组栏同一做法), // 内容区已由 `g.SetClip(layout.content)` 裁切(行可以“半截”显示,滚动更顺滑);
// 这样滚动时下层内容不会透出来。 // 已经完整滚出可视范围的行在上面就 `continue` 了,这里不再额外门控。
UiFillRoundRect(g, header, 3.0f * m_scale, signalColor); UiFillRoundRect(g, header, 3.0f * m_scale, signalColor);
UiFillRoundRect(g, header, 3.0f * m_scale, WithAlpha(signalColor, 0xE8)); UiFillRoundRect(g, header, 3.0f * m_scale, WithAlpha(signalColor, 0xE8));
// 条目栏悬停:在信号色叠底上再压一层强调色,给出"这一行对应哪条曲线"的提示
//(曲线本身的高亮见 `PaintLaneDerivedOverlays`)。
if (m_meanRowHoverLane == static_cast<int>(index) && m_meanRowHoverRef == ref)
{
UiFillRoundRect(g, header, 3.0f * m_scale,
WithAlpha(m_theme->accent, 0x2E));
}
// 左侧三角(仅勾选时存在):朝下 = 展开、朝右 = 收起。 // 左侧三角(仅勾选时存在):朝下 = 展开、朝右 = 收起。
const gd::RectF& triangle = layout.rowTriangles[rowIndex]; const gd::RectF& triangle = layout.rowTriangles[rowIndex];
@@ -4917,26 +4994,25 @@ namespace jdi
const wchar_t* label, int current, int minimum, const wchar_t* label, int current, int minimum,
int maximum, bool active) int maximum, bool active)
{ {
if (track.Width <= 0.0f)
{
return;
}
const float labelLeft = track.X - 6.0f * m_scale - const float labelLeft = track.X - 6.0f * m_scale -
UiMeasureTextWidth(m_fonts->uiSmall, label); UiMeasureTextWidth(m_fonts->uiSmall, label);
UiText(g, label, m_fonts->uiSmall, // ⚠️ **必须用 `UiTextClipped`(而不是 `UiText`)**:GDI 文本不受
gd::RectF(labelLeft, track.Y - 8.0f * m_scale, // GDI+ `SetClip` 约束,滚动时标签/数值会画到内容区之外
UiMeasureTextWidth(m_fonts->uiSmall, label), //(用户 2026-10-30 报告"元素超出了浮动面板"的直接原因)。
track.Height + 16.0f * m_scale), UiTextClipped(g, label, m_fonts->uiSmall,
m_theme->textMuted, TextAlign::LEFT); 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 = const double ratio =
(maximum > minimum) (maximum > minimum)
? (static_cast<double>(current - minimum) / ? (static_cast<double>(current - minimum) /
static_cast<double>(maximum - minimum)) static_cast<double>(maximum - minimum))
: 0.0; : 0.0;
UiDrawSlider(g, track, ratio, m_theme->accent, active, m_scale); UiDrawSlider(g, track, ratio, m_theme->accent, active, m_scale);
UiText(g, std::to_wstring(current), m_fonts->monoSmall, UiTextClipped(g, std::to_wstring(current), m_fonts->monoSmall,
gd::RectF(valueRect.X, valueRect.Y, valueColumn, valueRect.Height), gd::RectF(valueRect.X, valueRect.Y, valueColumn, valueRect.Height),
m_theme->textStrong, TextAlign::LEFT); m_theme->textStrong, TextAlign::LEFT, layout.content);
// 复位按钮:悬停高亮 + 与「系数/偏移」菜单同款图标。 // 复位按钮:悬停高亮 + 与「系数/偏移」菜单同款图标。
const bool hot = const bool hot =
(m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) && (m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) &&
@@ -4998,6 +5074,18 @@ namespace jdi
} }
g.Restore(meanSave); g.Restore(meanSave);
// ---- 内容滚动条(只在内容超高时出现;底部按钮不参与滚动)----
// ⚠️ 必须在 `g.Restore(meanSave)` **之后**画:滚动条位于内容区右侧、在
// 内容裁剪区之外,若在裁剪内画会被整条裁掉(用户 2026-10-30 报告
// "全部启用后没有出现滚动条"的根因)。
if (layout.scrollTrack.Width > 0.0f)
{
UiFillRoundRect(g, layout.scrollTrack, layout.scrollTrack.Width * 0.5f,
WithAlpha(m_theme->border, 0x88));
UiFillRoundRect(g, layout.scrollThumb, layout.scrollThumb.Width * 0.5f,
m_theme->borderStrong);
}
} }
// 「自由标记」面板:按信号分组的**分组栏**(信号色托底 + 三角 + 名称 + // 「自由标记」面板:按信号分组的**分组栏**(信号色托底 + 三角 + 名称 +
// 「N 标记点」+ ✕),下属固定点条目六列对齐(序号 / 取值 / 枚举 / 增量 / 时刻 / 间隔), // 「N 标记点」+ ✕),下属固定点条目六列对齐(序号 / 取值 / 枚举 / 增量 / 时刻 / 间隔),
@@ -5346,9 +5434,10 @@ namespace jdi
{ {
continue; continue;
} }
if (itemized || freeMarker) if (itemized || freeMarker || meanFilter)
{ {
// 逐条目绘制已在上面完成(自由标记同样是逐条目排布)。 // 逐条目绘制已在上面完成(自由标记同样是逐条目排布,
// 均值滤波走自己的可滚动分支)。
continue; continue;
} }
const std::uint32_t signalColor = StyleColor(*resolved.style); const std::uint32_t signalColor = StyleColor(*resolved.style);
+6 -5
View File
@@ -630,9 +630,9 @@ namespace jdi
layout.rowNameRects.push_back(gd::RectF(contentLeft, rowY, innerWidth, groupRow)); layout.rowNameRects.push_back(gd::RectF(contentLeft, rowY, innerWidth, groupRow));
layout.rowTriangles.push_back( layout.rowTriangles.push_back(
checked ? gd::RectF(contentLeft + 3.0f * m_scale, checked ? gd::RectF(contentLeft + 3.0f * m_scale,
rowY + (groupRow - kEdgePanelDisclosureWidth * m_scale) * 0.5f, rowY + (groupRow - kEdgePanelRowTriangleSize * m_scale) * 0.5f,
kEdgePanelDisclosureWidth * m_scale, kEdgePanelRowTriangleSize * m_scale,
kEdgePanelDisclosureWidth * m_scale) kEdgePanelRowTriangleSize * m_scale)
: gd::RectF()); : gd::RectF());
layout.rowChecks.push_back( layout.rowChecks.push_back(
gd::RectF(contentLeft + innerWidth - closeSize - 4.0f * m_scale, gd::RectF(contentLeft + innerWidth - closeSize - 4.0f * m_scale,
@@ -987,8 +987,9 @@ namespace jdi
group.pinned = (barTop > rowY + 0.5f); group.pinned = (barTop > rowY + 0.5f);
group.header = gd::RectF(layout.content.X, barTop, innerWidth, groupRow); group.header = gd::RectF(layout.content.X, barTop, innerWidth, groupRow);
group.triangle = gd::RectF(layout.content.X + 3.0f * m_scale, group.triangle = gd::RectF(layout.content.X + 3.0f * m_scale,
barTop + (groupRow - 12.0f * m_scale) * 0.5f, barTop + (groupRow - kEdgePanelRowTriangleSize * m_scale) * 0.5f,
12.0f * m_scale, 12.0f * m_scale); kEdgePanelRowTriangleSize * m_scale,
kEdgePanelRowTriangleSize * m_scale);
group.close = gd::RectF(layout.content.X + innerWidth - closeSize - 2.0f * m_scale, group.close = gd::RectF(layout.content.X + innerWidth - closeSize - 2.0f * m_scale,
barTop + (groupRow - closeSize) * 0.5f, closeSize, closeSize); barTop + (groupRow - closeSize) * 0.5f, closeSize, closeSize);
float pinnedBottom = barTop + groupRow; float pinnedBottom = barTop + groupRow;
+29 -3
View File
@@ -120,7 +120,23 @@ namespace jdi
std::uint32_t MixToLuminance(std::uint32_t color, double targetLuminance); std::uint32_t MixToLuminance(std::uint32_t color, double targetLuminance);
/** /**
* @brief 界面主题色板(Solarised Light 派生)。 * @brief 界面主题色板。
*
* ## 结构色的对比度约定(重要,改配色前必读)
*
* `gridWeak` / `gridStrong` / `axisLine` / `borderStrong` 这些“结构色”是**主题配色的一部分**
* (每个主题各自给值),它们画在**网格叠底**(`laneBg`)之上,而不是绘图区底色 `plotBg`。
* 因此它们的最小对比度一律针对 `laneBg` 校验:
*
* | 字段 | 最小对比度(相对 `laneBg`) |
* | --- | ---: |
* | `gridWeak` | 1.30 |
* | `gridStrong` | 1.70 |
* | `axisLine` | 2.10 |
* | `borderStrong` | 1.70 |
*
* 各主题给出的值本就满足上述下限,因此最终化时**逐位返回原色**、不做任何改色;
* 这些阈值只是“不允许配置出看不见的网格线”的兜底。
*/ */
struct Theme struct Theme
{ {
@@ -143,9 +159,19 @@ namespace jdi
std::uint32_t pressed = 0; /**< 按下底色。 */ std::uint32_t pressed = 0; /**< 按下底色。 */
std::uint32_t selection = 0; /**< 选中底色。 */ std::uint32_t selection = 0; /**< 选中底色。 */
std::uint32_t gridWeak = 0; /**< 次级网格线颜色。 */ std::uint32_t gridWeak = 0; /**< 次级网格线颜色(主要刻度 + 五等分线)。 */
std::uint32_t gridStrong = 0; /**< 主要网格线颜色。 */ std::uint32_t gridStrong = 0; /**< 主要网格线颜色(零线 / 加重线)。 */
std::uint32_t axisLine = 0; /**< 坐标轴线颜色。 */ std::uint32_t axisLine = 0; /**< 坐标轴线颜色。 */
/**
* @brief 每条网格内部的**叠底**颜色(绘图区底色之外的“卡片层”)。
*
* ⚠️ 网格线是画在**它**上面(不是 `plotBg` 上),因此结构色的对比度必须针对
* 本字段校验(见 `Theme.hpp` 顶部的 `EnsureContrast` 约定);落在深色主题上时
* 两者相差很大,只盯着 `plotBg` 算会让网格线“几乎看不见”(用户 2026-10-30 报告)。
*
* 0 表示“未设置”:最终化时回退到 `cardBg` 并保证与 `plotBg` 有最小对比度。
*/
std::uint32_t laneBg = 0;
std::uint32_t cursor = 0; /**< 时间光标颜色(单/双光标共用同一样式)。 */ std::uint32_t cursor = 0; /**< 时间光标颜色(单/双光标共用同一样式)。 */
std::uint32_t readoutBg = 0; /**< 数值读数底色。 */ std::uint32_t readoutBg = 0; /**< 数值读数底色。 */
std::uint32_t danger = 0; /**< 警示色。 */ std::uint32_t danger = 0; /**< 警示色。 */
+60 -39
View File
@@ -36,31 +36,40 @@ namespace jdi
}; };
/** /**
* @brief 把绘图区内部的"结构性"颜色按**底色**自动校正到足够对比(见 `EnsureContrast`)。 * @brief 主题**最终化**:确定网格叠底色,并保证结构色在**它**之上可辨。
* *
* 阈值(对比度)取"Solarised Light 现有配色刚好达标"的水位: * ⚠️⚠️ **参照底色必须是 `laneBg`(网格叠底),不是 `plotBg`**:
* 该主题的历史配色是人工调过的,达标时 `EnsureContrast` 逐位返回原色 ⇒ 默认主题 * 网格线画在每条网格内部的叠底上。深色主题里 `plotBg`(#1E1E1E)与 `laneBg`
* **一个像素都不会变**;其它主题(尤其深色)则自动把网格线/时间轴/分界线提亮。 * (#2D2D30)亮度差得远,只按 `plotBg` 校正会让网格线落进叠底色的亮度范围
* ⇒ 实测对比度只有 **1.07**(几乎看不见,用户 2026-10-30 报告)。
* *
* | 字段 | 阈值 | Solarised Light 现状 | * 阈值取"Solarised Light 现有配色刚好达标"的水位(针对 `laneBg` 实测:
* 1.335 / 1.771 / 2.395 / 2.395),因此默认主题**逐位不变**;其它主题若不足才被校正。
*
* | 字段 | 阈值(相对 `laneBg`) | Solarised Light 现状 |
* | --- | ---: | ---: | * | --- | ---: | ---: |
* | `gridWeak`(网格主刻线 + 五等分线基色) | 1.30 | 约 1.38 | * | `gridWeak`(网格主刻线 + 五等分线基色) | 1.30 | 1.335 |
* | `gridStrong`(零线/加重线) | 1.65 | 约 1.80 | * | `gridStrong`(零线/加重线) | 1.70 | 1.771 |
* | `axisLine`(时间轴、网格分界线、绘图区外框) | 2.10 | 约 2.43 | * | `axisLine`(时间轴、网格分界线、绘图区外框) | 2.10 | 2.395 |
* | `borderStrong`(网格间分界线) | 1.70 | 约 2.43 | * | `borderStrong`(网格间分界线) | 1.70 | 2.395 |
*/ */
Theme NormalizePlotContrast(Theme theme) Theme FinalizeTheme(Theme theme)
{ {
const std::uint32_t plot = theme.plotBg | 0xFF000000u; const std::uint32_t plot = theme.plotBg | 0xFF000000u;
theme.gridWeak = EnsureContrast(plot, theme.gridWeak | 0xFF000000u, 1.30); // 网格叠底:未显式配置时回退到 `cardBg`,并保证与绘图区底色可辨(1.03)。
theme.gridStrong = EnsureContrast(plot, theme.gridStrong | 0xFF000000u, 1.65); const std::uint32_t laneSource =
theme.axisLine = EnsureContrast(plot, theme.axisLine | 0xFF000000u, 2.10); (theme.laneBg != 0u) ? (theme.laneBg | 0xFF000000u) : (theme.cardBg | 0xFF000000u);
theme.borderStrong = EnsureContrast(plot, theme.borderStrong | 0xFF000000u, 1.70); theme.laneBg = EnsureContrast(plot, laneSource, 1.03);
theme.gridWeak = EnsureContrast(theme.laneBg, theme.gridWeak | 0xFF000000u, 1.30);
theme.gridStrong = EnsureContrast(theme.laneBg, theme.gridStrong | 0xFF000000u, 1.70);
theme.axisLine = EnsureContrast(theme.laneBg, theme.axisLine | 0xFF000000u, 2.10);
theme.borderStrong = EnsureContrast(theme.laneBg, theme.borderStrong | 0xFF000000u, 1.70);
return theme; return theme;
} }
/** @brief 全局唯一主题实例(Solarised Light;历史默认主题)。 */ /** @brief 全局唯一主题实例(Solarised Light;历史默认主题)。 */
const Theme kSolarizedLight = NormalizePlotContrast([] const Theme kSolarizedLight = FinalizeTheme([]
{ {
Theme theme; Theme theme;
theme.windowBg = MakeArgb(kBase3); theme.windowBg = MakeArgb(kBase3);
@@ -82,8 +91,10 @@ namespace jdi
theme.pressed = MakeArgb(0xD8CFB6u); theme.pressed = MakeArgb(0xD8CFB6u);
theme.selection = MakeArgb(0xDCEAF6u); theme.selection = MakeArgb(0xDCEAF6u);
// 网格线需要在绘图区底色(kBase3)上清晰可辨,同时又要与网格边界 // 网格线需要在**网格叠底**(kBase3 推暗 1.03 ≈ #F9F2E0)上清晰可辨,同时又要与
// (axisLine/borderStrong 的蓝灰)明显区分——因此采用偏暖的加深米色。 // 网格边界(axisLine/borderStrong 的蓝灰)明显区分——因此采用偏暖的加深米色。
// 这三个值相对叠底色的对比度分别是 1.335 / 1.771 / 2.395,已达标 ⇒ 最终化不改动。
theme.laneBg = MakeArgb(0xF9F2E0u);
theme.gridWeak = MakeArgb(0xDDD3B8u); theme.gridWeak = MakeArgb(0xDDD3B8u);
theme.gridStrong = MakeArgb(0xC3B896u); theme.gridStrong = MakeArgb(0xC3B896u);
theme.axisLine = MakeArgb(kBase1); theme.axisLine = MakeArgb(kBase1);
@@ -100,7 +111,7 @@ namespace jdi
* 与 Solarised Light 的区别:底色是**中性白/浅灰**而不是暖米色, * 与 Solarised Light 的区别:底色是**中性白/浅灰**而不是暖米色,
* 强调色是 VS Code 的系统蓝 `#0078D4`,描边是中性灰。 * 强调色是 VS Code 的系统蓝 `#0078D4`,描边是中性灰。
*/ */
const Theme kLight = NormalizePlotContrast([] const Theme kLight = FinalizeTheme([]
{ {
Theme theme; Theme theme;
theme.windowBg = MakeArgb(0xFFFFFFu); // editor.background theme.windowBg = MakeArgb(0xFFFFFFu); // editor.background
@@ -109,7 +120,7 @@ namespace jdi
theme.cardBg = MakeArgb(0xFFFFFFu); theme.cardBg = MakeArgb(0xFFFFFFu);
theme.plotBg = MakeArgb(0xFFFFFFu); theme.plotBg = MakeArgb(0xFFFFFFu);
theme.border = MakeArgb(0xE5E5E5u); // panel.border theme.border = MakeArgb(0xE5E5E5u); // panel.border
theme.borderStrong = MakeArgb(0xCECECEu); theme.borderStrong = MakeArgb(0xC3C3C3u);
theme.text = MakeArgb(0x3B3B3Bu); // foreground theme.text = MakeArgb(0x3B3B3Bu); // foreground
theme.textStrong = MakeArgb(0x1F1F1Fu); theme.textStrong = MakeArgb(0x1F1F1Fu);
@@ -122,11 +133,12 @@ namespace jdi
theme.pressed = MakeArgb(0xE8E8E8u); theme.pressed = MakeArgb(0xE8E8E8u);
theme.selection = MakeArgb(0xE8E8E8u); theme.selection = MakeArgb(0xE8E8E8u);
// 白底上网格线必须够淡(否则抢波形),但主刻线要能与次级线区分。 // 网格叠底比绘图区白底略暗一档,便于分辨相邻网格;
// ⚠️ 这三个颜色会被 `NormalizePlotContrast` 按白底自动校正到可见水位。 // 结构色按**叠底色**选中(对比度:1.30 / 1.70 / 2.10),不要再用原白底的值。
theme.gridWeak = MakeArgb(0xEDEDEDu); theme.laneBg = MakeArgb(0xFBFBFBu);
theme.gridStrong = MakeArgb(0xD6D6D6u); theme.gridWeak = MakeArgb(0xDEDEDEu);
theme.axisLine = MakeArgb(0xCECECEu); theme.gridStrong = MakeArgb(0xC3C3C3u);
theme.axisLine = MakeArgb(0xAFAFAFu);
theme.cursor = MakeArgb(0xD18616u); // VS Code charts.orange theme.cursor = MakeArgb(0xD18616u); // VS Code charts.orange
theme.readoutBg = MakeArgb(0xF3F3F3u); theme.readoutBg = MakeArgb(0xF3F3F3u);
theme.danger = MakeArgb(0xE51400u); // errorForeground theme.danger = MakeArgb(0xE51400u); // errorForeground
@@ -137,7 +149,7 @@ namespace jdi
/** /**
* @brief 深色主题(参考 VS Code Dark+)。 * @brief 深色主题(参考 VS Code Dark+)。
*/ */
const Theme kDark = NormalizePlotContrast([] const Theme kDark = FinalizeTheme([]
{ {
Theme theme; Theme theme;
theme.windowBg = MakeArgb(0x1E1E1Eu); // editor.background theme.windowBg = MakeArgb(0x1E1E1Eu); // editor.background
@@ -146,7 +158,7 @@ namespace jdi
theme.cardBg = MakeArgb(0x2D2D30u); theme.cardBg = MakeArgb(0x2D2D30u);
theme.plotBg = MakeArgb(0x1E1E1Eu); theme.plotBg = MakeArgb(0x1E1E1Eu);
theme.border = MakeArgb(0x3C3C3Cu); theme.border = MakeArgb(0x3C3C3Cu);
theme.borderStrong = MakeArgb(0x4A4A4Au); theme.borderStrong = MakeArgb(0x505050u);
theme.text = MakeArgb(0xCCCCCCu); theme.text = MakeArgb(0xCCCCCCu);
theme.textStrong = MakeArgb(0xFFFFFFu); theme.textStrong = MakeArgb(0xFFFFFFu);
@@ -159,11 +171,13 @@ namespace jdi
theme.pressed = MakeArgb(0x37373Du); theme.pressed = MakeArgb(0x37373Du);
theme.selection = MakeArgb(0x04395Eu); theme.selection = MakeArgb(0x04395Eu);
// ⚠️ 深色底上的原始取值几乎与底色同亮度(差 2~3 个色阶)⇒ 交给 // ⚠️ 深色主题的结构色**必须比旧值亮得多**:它们画在网格叠底(#2D2D30)上,
// `NormalizePlotContrast` 按 `plotBg` 自动提亮到可见水位。 // 旧值(#2B2B2B / #3A3A3A)与叠底几乎同亮度(实测对比度 1.07)⇒ 网格线看不见。
theme.gridWeak = MakeArgb(0x2B2B2Bu); // 这里的值按叠底色选:1.30 / 1.70 / 2.10。
theme.gridStrong = MakeArgb(0x3A3A3Au); theme.laneBg = MakeArgb(0x2D2D30u);
theme.axisLine = MakeArgb(0x4A4A4Au); theme.gridWeak = MakeArgb(0x3F3F3Fu);
theme.gridStrong = MakeArgb(0x505050u);
theme.axisLine = MakeArgb(0x5E5E5Eu);
theme.cursor = MakeArgb(0xD18616u); theme.cursor = MakeArgb(0xD18616u);
theme.readoutBg = MakeArgb(0x2D2D30u); theme.readoutBg = MakeArgb(0x2D2D30u);
theme.danger = MakeArgb(0xF44747u); theme.danger = MakeArgb(0xF44747u);
@@ -177,7 +191,7 @@ namespace jdi
* 底色用 base03、面板用 base02,强调色仍是 Solarised 那一套 —— 因此波形配色表 * 底色用 base03、面板用 base02,强调色仍是 Solarised 那一套 —— 因此波形配色表
* (`SignalPalette`)与 Solarised Light 完全一致。 * (`SignalPalette`)与 Solarised Light 完全一致。
*/ */
const Theme kSolarizedDark = NormalizePlotContrast([] const Theme kSolarizedDark = FinalizeTheme([]
{ {
Theme theme; Theme theme;
theme.windowBg = MakeArgb(kBase03); theme.windowBg = MakeArgb(kBase03);
@@ -186,7 +200,7 @@ namespace jdi
theme.cardBg = MakeArgb(kBase03); theme.cardBg = MakeArgb(kBase03);
theme.plotBg = MakeArgb(kBase03); theme.plotBg = MakeArgb(kBase03);
theme.border = MakeArgb(0x18505Fu); theme.border = MakeArgb(0x18505Fu);
theme.borderStrong = MakeArgb(kBase01); theme.borderStrong = MakeArgb(0x205363u);
theme.text = MakeArgb(kBase0); theme.text = MakeArgb(kBase0);
theme.textStrong = MakeArgb(kBase1); theme.textStrong = MakeArgb(kBase1);
@@ -199,8 +213,11 @@ namespace jdi
theme.pressed = MakeArgb(0x0A3F4Cu); theme.pressed = MakeArgb(0x0A3F4Cu);
theme.selection = MakeArgb(0x12414Fu); theme.selection = MakeArgb(0x12414Fu);
theme.gridWeak = MakeArgb(0x0B3A46u); // 网格叠底与绘图区底色同为 base03 时看不出网格层次 ⇒ 显式给出略亮一档的叠底,
theme.gridStrong = MakeArgb(0x14495Au); // 结构色按**它**选(对比度 1.30 / 1.70 / 2.10)。
theme.laneBg = MakeArgb(0x032E38u);
theme.gridWeak = MakeArgb(0x13414Cu);
theme.gridStrong = MakeArgb(0x205363u);
theme.axisLine = MakeArgb(kBase01); theme.axisLine = MakeArgb(kBase01);
theme.cursor = MakeArgb(kOrange); theme.cursor = MakeArgb(kOrange);
theme.readoutBg = MakeArgb(kBase02); theme.readoutBg = MakeArgb(kBase02);
@@ -316,10 +333,14 @@ namespace jdi
std::uint32_t ThemeLaneBackground(const Theme& theme) std::uint32_t ThemeLaneBackground(const Theme& theme)
{ {
// 网格底色必须与绘图区底色**可辨**(否则几十条网格看起来是一整片)。 // 网格叠底色是**主题配色的一部分**(`Theme::laneBg`),由 `FinalizeTheme` 在构建主题时
// 用统一的对比度阈值 1.03:本就有差异的主题(Solarised Light / Light / Dark) // 保证与绘图区底色可辨;这里只做"未配置"的兜底。
// 逐位返回 `cardBg`;两侧完全同色的主题(Solarised Dark 的 base03)会被轻轻推开。 if (theme.laneBg != 0u)
return EnsureContrast(theme.plotBg | 0xFF000000u, theme.cardBg | 0xFF000000u, 1.03); {
return theme.laneBg | 0xFF000000u;
}
const std::uint32_t plot = theme.plotBg | 0xFF000000u;
return EnsureContrast(plot, theme.cardBg | 0xFF000000u, 1.03);
} }
const Theme& ThemeFor(ThemeKind kind) const Theme& ThemeFor(ThemeKind kind)
@@ -749,6 +749,15 @@ namespace jdi
*/ */
void BuildRows(); void BuildRows();
/**
* @brief 行集合的“定义状态”签名(分组集合 / 每组可见项数 / 折起状态 / 是否有动画)。
*
* `EnsureRows` 用它做**自愈校验**:只要签名与“上一次建行集合时的签名”不一致,
* 就重建行集合。这样可以彻底堵住“改了折起状态却忘了置 `m_rowsDirty`”这类缺陷
*(用户 2026-10-30 报告:组名三角已显示展开,但下属信号行完全不出现)。
*/
std::uint64_t RowsSignature() const;
/** /**
* @brief 在需要时重建列表行集合。 * @brief 在需要时重建列表行集合。
*/ */
@@ -1337,6 +1346,7 @@ namespace jdi
*/ */
std::vector<float> m_rowTop; std::vector<float> m_rowTop;
bool m_rowsDirty = true; /**< 列表行集合是否需要重建。 */ bool m_rowsDirty = true; /**< 列表行集合是否需要重建。 */
std::uint64_t m_rowsSignature = 0; /**< 建行集合时的 `RowsSignature()`(用于自愈校验)。 */
float m_contentHeight = 0.0f; /**< 列表内容总高度。 */ float m_contentHeight = 0.0f; /**< 列表内容总高度。 */
float m_listFullWidth = 0.0f; /**< 列表区不含滚动条的完整宽度。 */ float m_listFullWidth = 0.0f; /**< 列表区不含滚动条的完整宽度。 */
bool m_scrollBarVisible = false; /**< 是否需要显示滚动条。 */ bool m_scrollBarVisible = false; /**< 是否需要显示滚动条。 */
@@ -518,6 +518,8 @@ namespace jdi
{ {
m_rows.clear(); m_rows.clear();
m_rowsDirty = false; m_rowsDirty = false;
// 记住“本行集合对应的定义状态”(含空数据集的早退路径),供 `EnsureRows()` 做自愈校验。
m_rowsSignature = RowsSignature();
// 行集合重建后行下标的含义可能变化(增删/折起展开)⇒ 行位图缓存整体作废。 // 行集合重建后行下标的含义可能变化(增删/折起展开)⇒ 行位图缓存整体作废。
// 这同时把"不再显示的行"的位图内存还回去(缓存条目按行数增长)。 // 这同时把"不再显示的行"的位图内存还回去(缓存条目按行数增长)。
InvalidateRowCache(); InvalidateRowCache();
@@ -588,6 +590,37 @@ namespace jdi
UpdateAnimGeometry(); UpdateAnimGeometry();
} }
std::uint64_t SignalPanel::RowsSignature() const
{
// FNV-1a:混合“每个分组是否列出、列出多少项、是否折起、是否有折起动画”。
// 这四项恰好完全决定行集合的内容,因此它们不变时无需重建。
std::uint64_t hash = 1469598103934665603ull;
const auto mix = [&hash](std::uint64_t value)
{
hash = (hash ^ value) * 1099511628211ull;
};
if (m_dataSet == nullptr || m_tab == nullptr)
{
return hash;
}
const std::vector<SignalGroup>& groups = m_dataSet->Groups();
mix(static_cast<std::uint64_t>(groups.size() + 1));
// 筛选态下“每组列多少项”要逐信号比对(成本 O(信号数)),因此只在非筛选时入签名;
// 筛选文本变化本身会走 `ApplyFilterText()` 置脏,无需靠签名发现。
const bool filtering = !m_filterText.empty();
mix(filtering ? 1ull : 0ull);
for (std::size_t index = 0; index < groups.size(); ++index)
{
const int groupIndex = static_cast<int>(index);
const std::uint64_t count =
filtering ? 0u : static_cast<std::uint64_t>(groups[index].signals.size());
mix(count + 2u);
mix(IsGroupCollapsed(groupIndex) ? 3u : 0u);
mix((FindGroupAnimation(groupIndex) >= 0) ? 5u : 0u);
}
return hash;
}
void SignalPanel::EnsureRows() void SignalPanel::EnsureRows()
{ {
if (m_rowsDirty) if (m_rowsDirty)
@@ -596,6 +629,17 @@ namespace jdi
RefreshScrollBar(); RefreshScrollBar();
return; return;
} }
// ⚠️ **自愈不变量**:行集合必须与“分组集合 / 每组可见项数 / 折起状态 / 是否有动画”
// 严格一致。历史上确实出现过“折起状态变了但忘了置 `m_rowsDirty`”的路径 ——
// 表现就是用户 2026-10-30 报告的:组名三角已经显示“展开”,
// 但下属信号行一条都不出现(行集合里根本没有它们)。
// 这里每帧花 O(分组数) 校验一次签名,不一致就地重建,不再依赖每个调用点自觉置脏。
if (m_rowsSignature != RowsSignature())
{
BuildRows();
RefreshScrollBar();
return;
}
if (m_rowShift.size() != m_rows.size()) if (m_rowShift.size() != m_rows.size())
{ {
// 行集合没变但位移缓存与它不同步(例如首次构建后)—— 补齐,避免读到越界值。 // 行集合没变但位移缓存与它不同步(例如首次构建后)—— 补齐,避免读到越界值。
+179 -2
View File
@@ -8687,13 +8687,15 @@ Solarised Light 的 `0xFFFDF6E3`,这是"换深色主题后预览曲线永远
* 条目 = **信号色叠底标题栏**(左侧展开三角 + 名称 + 右端**复选框**)。勾选前只有标题栏; * 条目 = **信号色叠底标题栏**(左侧展开三角 + 名称 + 右端**复选框**)。勾选前只有标题栏;
勾选后出现三角与两行滑块,三角可收起参数行。复选框自绘(对比色描边/填充), 勾选后出现三角与两行滑块,三角可收起参数行。复选框自绘(对比色描边/填充),
主题色在信号色叠底上不可辨。 主题色在信号色叠底上不可辨。
* 两行滑块:`均值点数`(0…上限)与 `迭代次数`(1…上限),**标签不带 X / N**; * 两行滑块:`均值点数`(1…上限,**2026-10-30 第十七轮把下限从 0 改为 1**)与
`迭代次数`(1…上限),**标签不带 X / N**;
右侧数值可点击输入**滑块上限**(沿用"输入上限(当前N)"提示与 `EditFilter::BYTE`), 右侧数值可点击输入**滑块上限**(沿用"输入上限(当前N)"提示与 `EditFilter::BYTE`),
最右侧是复位按钮(点数 → 5 且上限 → 50;迭代 → 1 且上限 → 10)。 最右侧是复位按钮(点数 → 5 且上限 → 50;迭代 → 1 且上限 → 10)。
新增 `Lane::MeanFilterItem::{maxPoints, maxIterations, collapsed}`、`Lane::meanFilterScroll`。 新增 `Lane::MeanFilterItem::{maxPoints, maxIterations, collapsed}`、`Lane::meanFilterScroll`。
* 内容区超高时**滚动**(`layout.content` + 滚动条 + 滚轮),底部「构建为新信号」按钮固定。 * 内容区超高时**滚动**(`layout.content` + 滚动条 + 滚轮),底部「构建为新信号」按钮固定。
面板高度上限 = 绘图区可用高度 ⇒ 上百信号合并时不再顶出屏幕。 面板高度上限 = 绘图区可用高度 ⇒ 上百信号合并时不再顶出屏幕。
* 命中要求"行**完全落在内容区**里",避免滚动出的行抢底部按钮的点击。 * 命中:只要**点击/悬停点落在内容区内**即算命中(行可以是"半截"显示的,与绘制一致);
第十七轮已删掉旧的"行必须完全落在内容区内"限制(它造成"看得见却点不到")。
* 缓存键:`PanelSlotKey` 增补 `rowHeaders / rowTriangles / scrollOffset / MarkerGroup::check / * 缓存键:`PanelSlotKey` 增补 `rowHeaders / rowTriangles / scrollOffset / MarkerGroup::check /
meanItem->maxPoints / maxIterations / collapsed`;`meanFilterScroll` **只进槽键**(不进全局键, meanItem->maxPoints / maxIterations / collapsed`;`meanFilterScroll` **只进槽键**(不进全局键,
否则滚动一个面板会让所有面板重画)。 否则滚动一个面板会让所有面板重画)。
@@ -8764,3 +8766,178 @@ make debug -> 零 error 零 warning
`PlotView::` 前缀。 `PlotView::` 前缀。
* 探针的工程根目录推导:探针若放在 `.preflight\xxx.exe`,从 exe 路径**上溯 2 级**即根目录 * 探针的工程根目录推导:探针若放在 `.preflight\xxx.exe`,从 exe 路径**上溯 2 级**即根目录
(上溯 3 级会跑到桌面上,`TestData` 找不到)。 (上溯 3 级会跑到桌面上,`TestData` 找不到)。
---
## 2026-10-30 第十七轮(本机):均值面板越界根因 + 网格线进入主题配色 + 悬停/整行点击 + 点数下限 1
### 1. 「元素超出浮动面板」的真正根因(**最重要,别再误判**)
**症状**:均值滤波面板内容超高、滚到底时,**标题栏上**出现一条信号名 + 一个蓝色复选框(无信号色底)。
**曾经的错误判断**:以为是「GDI+ `SetClip` 在滚动面板里不可靠」,于是加了「只画完全落在
`layout.content` 里的行」的双保险 —— 完全无效(像素差 789/1808 **一位都没变**)。
**真因**:`PaintEdgeCursor` 里绘制侧的 `itemized` 判定仍为
`(centerAxis || meanFilter || weakPeak)`,而布局侧的 `EdgePanelLayoutFor` 早已改为
`(centerAxis || weakPeak)`(均值滤波有自己的可滚动分支)。于是**均值面板被旧「逐条目」分支
又画了一遍**:它给每行画 `名称 + 复选框`,**没有任何裁切**,行按 `layout.rowHeaders` 的坐标画
(滚动后就是负的/越界的 Y)⇒ 直接画到标题栏上。
**修法**:
* 绘制侧 `itemized = (centerAxis || weakPeak)`(与布局侧统一);
* 旧「逐条目」兜底循环的跳过条件补上 `meanFilter`(否则均值面板会被当成普通面板画两行文字);
* 底部「构建为新信号」按钮由**共享的 `if (hasButton)`** 绘制,不受影响。
**教训**:同一语义的标志位在「布局」和「绘制」两处各有一份时,改一处必须同步另一处;
出现"画出来的东西比布局多"时,先怀疑**有第二个绘制分支**,而不是怀疑裁剪 API。
### 2. 顺带恢复:半截行正常显示
真因修掉后 `g.SetClip(layout.content)` 是够用的,于是删除了「`fullyInside` 门控」
(行必须整行落在内容区)。现在滚动时行可以**半截**显示,滚动观感连续、不再跳空档。
探针 `meanpix`:`titleDiff(top vs mid)=0`、`titleDiff(mid vs max)=0`、`outsideDiff=0`(面板外 0 差异),
而 `contentDiff` 仍然很大(内容确实在滚动)。
### 3. 条目栏交互:整行点击 + 悬停高亮预览曲线
* **整行可点**(不再只能点 12px 的三角):点击落在右端复选框上 → 切换"是否预览";
已启用 → 切换参数行的收起/展开;
**尚未启用 → 什么都不做**(⚠️ 2026-10-30 第十八轮推翻:原实现会"直接开始预览",
用户明确要求未勾选前点击条目栏不得自动勾选/展开收起,只能点复选框)。
* **悬停行 → 对应预览曲线高亮**:`m_meanRowHoverLane / m_meanRowHoverRef` 由 `OnMouseMove` 维护,
`OnMouseLeave` / `InvalidateDerivedCaches` 清除;绘制侧
`PaintDerivedCurve(..., bool highlight)` 先画 `+5px` 半透明同色光晕、再把线加粗 `+1.6px`,
并在行头叠一层 `accent 0x2E`。
* 悬停状态变化会 `InvalidateSceneCache()`(派生曲线属于"静态场景",否则高亮要等下一次重绘)。
* 命中不再要求行"完全在内容区内"(见上),但**命中点本身必须落在 `layout.content` 里**,
所以不会抢底部按钮的点击,也不会出现"看不见却能点到"的热区。
### 4. 均值点数下限 1(用户明确要求:不可以为 0)
* `kMeanFilterPointsMin` 由 **0 → 1**(`PlotViewInternal.hpp`);
* 输入提交路径:`pointsMin = centerTarget ? 0 : kMeanFilterPointsMin`
(中心轴边沿计数沿用旧语义 0 = 不平滑,不跟着改);
* 预览:`PaintMeanFilterCurve` 内 `points/iterations` 再夹一次下限 ⇒ 即使条目被外部写成 0,
画出来也与 1 点完全一致(探针断言"0 与 1 的整帧像素 0 差异");
* 构建请求 `MeanFilterBuild.points` 同样夹到下限 ⇒ 预览与「构建为新信号」仍逐位一致。
### 5. 网格线颜色成为主题配色的一部分
* `struct Theme` 新增 `laneBg`(网格叠底色,= 绘图区在该主题下的真实底色)。
* 全部结构性颜色(`gridWeak / gridStrong / axisLine / borderStrong`)统一**对 `laneBg`**
做 WCAG 对比度归一(`FinalizeTheme`,契约:gridWeak ≥1.30、gridStrong ≥1.70、
axisLine ≥2.10、borderStrong ≥1.70),并给出每个主题的显式取值。
* Dark 主题原本 gridWeak 对 lane 只有 **1.071**(几乎不可见)⇒ 现改为 ≥1.30;
其它主题同步微调,四个主题的网格都清晰可辨。
### 6. 验证
```
uicheck 69/69 lbcheck 21/21 lbresid 5/5 lbzorder 4/4
lbcovr 7/7 lbpanel 10/10 derivecheck 16/16
round15check 28/28 themecheck 17/17
meancheck 17/17 (含:滚动条确实被绘制、三角 12 逻辑像素、整行点击可反复切换、
悬停记录/清除、点数 0 与 1 等价、同状态两帧逐像素一致)
meanpix 标题栏 / 面板外:滚动前后 0 像素差异
rendercheck 8 个场景哈希与基线完全一致(A…H 未变)
make release -> 零 warning(805,376 B);make debug -> 零 warning
```
### 7. 踩坑(本轮新增)
* ⚠️⚠️ **探针 `build.ps1` 只编译 `main.cpp`,链接的是 `Build/Release/*.o`**:
改完工程源码后只重建探针 ⇒ 探针仍在跑**旧目标文件**,诊断打印"凭空消失"、
修复"看起来无效"。**先 `make release` 再重建探针**,并且**不要用
`Select-String error` 过滤 `make` 输出后忽略非零退出码**(本轮就因为一次
`const gd::RectF& header` 重复声明导致 `make` 失败而被过滤掉,白查了半小时)。
* ⚠️ 探针里断言"某行已启用"前要确认条目真的存在:`SetLaneMeanFilter(lane,true)` 会**自动启用
第一个信号**,再"全量切换"会把它关掉 ⇒ 出现 39/40 这种偏差,断言就会落在"未启用的行"上,
而“未启用的行单击”自**第十八轮**起是**完全无响应**(早期是“开始预览”),断言必须挑**已启用**的行。
* ⚠️ 异步派生曲线(大信号走后台线程)让"改参数 → 立刻截图比对"变得不稳定:
每个状态**连渲染两帧**再取像素,才是稳定态。
---
## 2026-10-30 第十八轮(本机):面板动画帧不再重绘绘图区(卡顿根治)+ 均值面板两处显示/交互 + 行集合自愈
### 1. 组名展开/收起卡顿的**真因**:面板动画帧每帧都整幅重绘绘图区
用户复现:`MF4-Record.MF4` + `VERY_BIG_DATA.CSV` 全部信号合并到一个网格,
开「弱峰调整 / 中心轴边沿计数」或**只开双光标**,点信号列表组名 → 展开/收起动画非常卡顿,
而网格里没有任何需要重绘的变化。
**测量(`.preflight/panelperf`,283 信号同网格)**:
| 场景 | 单帧耗时 |
| --- | --- |
| `PlotView::Paint`(双光标) | **31~46 ms** |
| `PlotView::Paint`(无光标) | 7~13 ms |
| 组名动画帧(修复前) | **34~43 ms/帧** |
| 组名动画帧(修复后) | **1.6~2.1 ms/帧** |
**根因链**:滚动定时器给面板动画帧做的是**整窗失效**(`InvalidateRect(hwnd,nullptr)`)⇒
`WM_PAINT` 里"更新区域不在绘图区内 ⇒ `m_chromeDirty = true`"这条启发式把整帧判成脏 ⇒
`Render` 走 `fullChrome` 分支 ⇒ **标签栏/工具栏/绘图区全部重绘**。
绘图区本身走场景缓存(~1ms),但**光标叠加层每帧都要重画**:288 个信号 ×
(圆角胶囊 + GDI 文本 + 重叠避让)≈ 24~40ms。于是每一帧都掉到 25fps 以下。
**修法(三处,缺一不可)**:
1. 新增 `Application::m_panelAnimationFrame`:滚动定时器在"只有面板动画在跑"时置位;
`WM_PAINT` 命中它**且**没有任何其它消息要求装饰区重绘(`m_chromeDirty` 仍为假)时,
**不再**把整帧判脏 ⇒ 走 `panelOnly` 分支(只画面板 + 分隔条)。
2. 新增 `Application::m_plotPaintValid`:`panelOnly` 帧且该标志为真时**跳过 `PlotView::Paint`**
—— 面板不覆盖绘图区,上一帧的像素原样留在后备缓冲区里,整窗回送也不会错。
作废点:`InvalidatePlotRect()`(签名改为多收一个 `bool&`,5 个调用点统一传
`m_plotPaintValid`)、`Relayout()`、`EnsureBackBuffer()`、`WM_PAINT` 里"更新区域就在绘图区内"、
以及"面板动画帧里绘图区动画(网格滚动 / 回到最上方)同时也在跑"。
3. `panelOnly` 分支画分隔条时**把右边界夹到 `floor(m_plotRect.X)`**:
分隔条矩形是浮点的(457.5…462.5),GDI+ 会把最后一列按 50% 混进去;整帧重绘时绘图区随后
会盖掉它,但跳过绘图区的帧会留下**一整列 1px 接缝**(实测 847 像素)。
探针断言"动画最后一帧的绘图区与紧接的整幅重绘**逐像素相同**"就是这个坑逼出来的。
### 2. 均值面板:两处显示/交互修正
* **未勾选条目栏不再响应点击**(用户 2026-10-30 要求):未勾选时点击名称区域
**既不会自动勾选、也不会展开/收起**,只能点右端复选框(点击仍被消费,不穿透)。
已勾选时整行点击仍是展开/收起。
* **滚动时滑块不再消失**:原实现按**条目栏矩形**判断整行是否可见,条目栏滚出内容区后
整行(含下面两行参数滑块)被 `continue` 跳过 ⇒ 滑块凭空消失。
现在按**整行纵向范围**(条目栏 ∪ 两行轨道/数值/复位)判断,只要还有任何一部分在内容区内就照常绘制
(裁剪仍由 `g.SetClip(layout.content)` 负责)。
### 3. 信号列表:行集合与折叠状态**自愈**
用户报告:开自由标记并标记了几个点后,点 `MF4-Record.MF4` 组名,三角已显示"展开",
但下属 283 条信号行一条都不出现。逐一排查后确认这只能是
**"折叠状态改了、行集合没重建"**(`m_rows` 里根本没有那些行)——
直接切 `TabConfig` 折叠状态的探针能 100% 复现该表现。
**修法(自愈不变量)**:新增 `SignalPanel::RowsSignature()`(FNV 混合
"分组集合 / 每组信号数 / 是否折起 / 是否有折起动画"),`BuildRows()` 记录它,
`EnsureRows()` 每帧花 O(分组数) 校验一次,**不一致就地重建**。
这样任何"忘记置 `m_rowsDirty`"的路径(数据层变化、自由标记、外部调用……)
都不会再让行集合与折叠状态脱节。
### 4. 验证
```
uicheck 69 / lbcheck 21 / lbresid 5 / lbzorder 4 / lbcovr 7 / lbpanel 10
derivecheck 16 / round15check 28 / themecheck 17 / meancheck 21
.preflight/panelbug 14/14(含"外部改折叠状态 → 行集合自愈"、"动画中途改状态 → 位移清零")
.preflight/panelperf 12/12(动画帧 <16ms + 跳过绘图区后像素与整幅重绘**完全一致**)
rendercheck 8 个场景哈希与基线完全一致;make release / make debug 零 warning(805,888 B)
```
### 5. 踩坑(本轮新增)
* ⚠️ **"只改一小块像素"的路径必须自己保证不越界**:`panelOnly` 帧只画面板+分隔条,
任何一点点溢出(浮点矩形的抗锯齿边缘)都会在绘图区留下**永久的 1px 接缝**。
判据要写成"动画最后一帧 vs 紧接的整幅重绘**逐像素相同**",而不是"看起来没问题"。
* ⚠️ 与"跳过绘图区"配套时,**必须**同时检查"绘图区自己的动画是否在跑"
(网格滚动到可视区 /「回到最上方」淡入淡出),否则会把它冻住。
* ⚠️ 探针断言里先打印真实状态(`m_rows.size()` / `m_rowShift` / 折叠标志 / `m_collapsed.size()`)
再写断言:本轮因为前面场景留下的折叠状态,导致"collapsedRows / expandedRows"语义正好反了,
白改了一轮断言。
* ⚠️ PowerShell 里用 `'...'` 拼接含 `%d` / 引号 / 长中文的补丁串极易被截断(出现 `>>` 续行提示后
只能 Ctrl+C)。**补丁一律写成 `.git\xxx.ps1`(纯 ASCII)+ `%TEMP%\xxx.txt` 数据文件**。