Files
Just-DataInspector/Documents/agent_mem.md
T
DeepSeek V4.1 Flash 71195c4c48 新增主题配色(4 套)+ 信号右键菜单精简与「克隆该信号」+ 布局边界层级根治 + 单点信号吸附修复
## 为什么

用户 2026-10-29 报告/要求五件事:

1. 派生信号的「时间轴偏移保留在样式上、其它调整烘值」维持现状(确认无需改动);
2. 信号名右键菜单里很多功能已被下方样式自定义区覆盖 ⇒ 只保留「仅显示该信号」,
   并在它**上方**新增「克隆该信号」;
3. 设置菜单新增「主题配色」二级菜单(带 √ 状态),顺序 Light / Solarised Light /
   Dark / Solarised Dark,默认主题维持现状;
4. 布局边界:中键平移视界时浮动面板上的边界**闪烁**;光标取值胶囊的**文字**框会**穿透**
   到浮动面板上;并请核对网格内固定构件(图例 / Y 轴标尺 / GX 标签 / 回到最上方 /
   浮动面板)的层级关系;
5. 单数据点信号在拖动光标时的**吸附**功能消失。

## 根因

### 布局边界层级(两个都很隐蔽)

- **文本层号写死**:文本是在绘制过程中由 `UiTextRectSink` 收集的,而宿主把所有文本都记在
  固定层 2;组件几何层却是"组件表下标"(`PlotView` = 0)。面板层 = PlotView 层 + 1 = 1,
  **低于**文本层 2 ⇒ 面板的遮挡矩形挖不到文本 ⇒ 胶囊文字画在面板表面。
- **面板像素缓存**:面板"先画进图层、命中缓存就贴图",贴图那一帧不画字 ⇒ 文本框凭空消失
  ⇒ 面板上的边界闪烁。
- 顺带发现 `PlotView::BuildBoxes` 用 `PushLayer()` 却**从不复位**,会把宿主后续所有组件的
  层号整体推高。

### 单点信号吸附

`MinorCellSparse` 在"把判定范围收窄到数据实际时间跨度"的分支里有
`if (!(dataMax > dataMin)) return false;`。单个有效采样点时 `dataMax == dataMin`,
于是被判定成"不稀疏" ⇒ 不吸附。

## 改了什么

### 1. 布局边界层级

- `UiTextRectSink` 新增 `OnTextLayer(int)` / `TextLayer()`;新增 `UiSetTextLayer` /
  `UiGetTextLayer`;`UiBoxTable` 实现为改 `m_layer`,并暴露 `Layer()`。
- `Application::Render` 在绘制每个组件前下发 `UiSetTextLayer(ComponentLayer(&component))`,
  浮层用 `OverlayLayer(1/2)`;新增 `ComponentLayer()` / `OverlayLayer()` /
  `kLayersPerComponent = 3`,与 `PaintLayoutBounds` 的层号**完全同源**。
- `PaintLayoutBounds` 改为**显式** `SetLayer(下标 × 3)`(不再依赖 `PushLayer` 累加)。
- `PlotView`:`BuildBoxes` 用 `baseLayer + 1` 放浮动面板(并登记遮挡)、
  `baseLayer + 2` 放绘制在面板之上的滚动条 / 回到最上方 / 输入框,结束**复位**层号;
  `paintOverlays` 同步分两次抬高文本层。
- 面板像素图层新增 `PanelSlot::textRects` + 转发型收集器 `PanelTextCollector`:
  渲染时存文本框,命中缓存贴图时回放 ⇒ 几何与缓存命中无关(消除闪烁)。

### 2. 信号右键菜单 + 克隆

- 菜单只剩:标题、**克隆该信号**、**仅显示该信号**;删除显示/隐藏、显示全部/隐藏全部、
  预设色块行、样式三项,以及对应的 `CMD_SIGNAL_TOGGLE/SHOW_ALL/HIDE_ALL`、
  `CMD_COLOR_BASE`、`CMD_MODE_BASE` 及其分支(死代码)。
- 新增 `CMD_SIGNAL_CLONE`(post `WM_APP + 5`)→ `Application::CloneSignal`:
  把「调整」(系数 / 绝对值 / 镜像 / Y 偏移)烘进采样值、**时间轴偏移保留在样式上**,
  名称前置 `*`,插入到源信号**正下方**,并**保留原外观**(颜色/透明度/线宽/绘制样式)。
- 抽出 `Application::InsertSignalBelowSource(...)` 供派生与克隆共用。

### 3. 主题配色

- `ThemeKind{LIGHT, SOLARIZED_LIGHT, DARK, SOLARIZED_DARK}` + `ThemeFor()` / `ThemeName()`;
  Light 取 VS Code 2026 默认浅色、Dark 取 Dark+、Solarized Dark 取 VS Code solarized dark。
  **默认仍是 Solarised Light**。
- 新增 `CurrentTheme()` / `SetCurrentTheme()`:宿主交出自己的 `m_theme` 地址,
  切换主题只改内容;`UiControls` 里没有 `Theme` 形参的原语改用它(原来硬编码
  `SolarizedLight()`,换主题不生效)。
- `Application::ApplyTheme()`:改配色 → `PlotView::OnThemeChanged()`(作废场景/宽基底/
  装饰/光晕/面板图层)+ `SignalPanel::OnThemeChanged()`(作废行位图)→ 整窗重绘。
- 菜单用 `SUBMENU` + 4 个 `keepOpen` 子项;新增 `PopupMenu::UpdateSubmenuItemLabel()`
  回写勾选,并让子菜单分支尊重 `keepOpen`(原为无条件关闭)。

### 4. 单点信号吸附

`MinorCellSparse` 改为只在 `dataMax < dataMin` 时放弃;相等时按"那一个小格"判定
(`need = 5 > finiteCount = 1` ⇒ 稀疏 ⇒ 吸附恢复)。

### 5. 审计

`Makefile` 的 `LAYOUT_BOUNDS` / `JDI_LAYOUT_BOUNDS` **从未被任何 `#if` 引用**
(`LAYOUT_BOUNDS=0` 产物字节数完全相同),属名实不符的死开关 ⇒ 删除并注明原因。

## 怎么验证

新增两支离屏探针(不占鼠标 / 键盘 / 屏幕):

```
.preflight/round15check  28 passed, 0 failed
  单点吸附 7 条(含"远处不吸附")、面板层级 5 条、面板文本框跨帧与中键平移稳定 2 条、
  克隆 14 条(烘值逐点比对 / 名称前缀 / 偏移与外观保留 / 调整复位)
.preflight/themecheck    17 passed, 0 failed
  4 套主题可读性与可区分、默认 = Solarised Light、逐主题换色且无旧底色残留、
  切回默认逐像素还原(diff = 0)、子菜单 4 项 / 顺序 / 唯一勾选 / 命令切换 / 菜单保持打开
```

回归全绿:

```
uicheck 69/69   lbcheck 21/21  lbresid 5/5   lbzorder 4/4
lbcovr   7/7    lbpanel 10/10  derivecheck 16/16
rendercheck: 8 个场景哈希与上一轮逐一相同 + FUNC 7/7, exit=0
  A FAED59DF284153DB  B E6443BFE4E3955F8  C 96A0AA8C402F1729  D E3D5DBD998E43720
  E DA0583CB13C26A6F  F FFB822159F604D1D  G 19F8DE8FE3A8DF18  H DBBDDE4CB2C97C92
```

`make release` / `make debug` 零 error / 零 warning;产物 **791,552 B**
(单一 exe,未拆 DLL,未改 `-Oz`;离 1MB 预警线约 250 KB)。
2026-10-29 20:38:55 +08:00

8614 lines
866 KiB
Markdown
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.
# Just DataInspector — Agent 记忆与项目须知
> 本文件是本仓库的**权威上下文备忘录**,供任何接手的 AI Agent 在动手前阅读。
> **每次完成任务后必须同步更新本文件**(补充新需求、修复记录、约定变更等)。
> 保持内容**精确、明细、简练**;多台设备协作时,可将各自的记忆合并到本文件。
---
## 1. 项目目标
- 使用 **C++17 + Win32 API(GDI/GDI+)** 从零构建的桌面数据查看器,项目名 **Just DataInspector**。
- 定位:替代 MATLAB/Simulink 的 `Simulink.sdi.view` 仿真数据检查器,用于更方便地查看**带时间轴的 CSV** 仿真数据。
- 目标数据特征(参考 MathWorks 导入 CSV 的说明):CSV 中存在**一个或多个时间轴列**,其余列表示各时间点上的信号值。
- 产物为 **GUI 程序**(非 CUI)。
---
## 2. 构建与工具链
- 工具链:**MinGW-w64**(`x86_64-win32-seh-rev0`, MinGW-Builds 16.1.0),可直接用 `make`。
- 构建入口:根目录 `Makefile`(C / C++ / windres 混合构建)。
- 常用命令:
- `make release` → `Build/Release/Target.exe`(`-Os`,strip:`-s`)。
- `make debug` → `Build/Debug/Target.exe`(`-O0 -g`)。
- `make` → 同时构建 Release 与 Debug。
- `make clean` → 删除 `Build/`。
- 关键编译设置:`-std=c++17`;`-finput-charset=UTF-8 -fexec-charset=GBK -fwide-exec-charset=UTF-16LE`;`-static`;`-mwindows`(GUI);链接 `gdi32 gdiplus comdlg32 d3d11 dwmapi d3dcompiler`。
- **新增组件/工具目录后,必须在 `Makefile` 的 `CXX_INCLUDES_PATHS` 与 `CXX_SOURCES_PATHS` 中登记**(源文件按目录通配自动收集)。
- 构建产物统一在 `Build/`,已由 `.gitignore` 忽略;同时忽略 `*.o/*.d/*.lst/*.map/*.exe` 与 `TestData/`。
---
## 3. 目录结构与约定
```
Components/ 功能组件块(每块 Inc/ 放 .hpp,Src/ 放 .cpp)
Application/ 应用主框架:窗口、布局、消息分发、标签页、绘制合成
Main/ 程序入口(WinMain → Application::Run)
TabBar/ 顶部多标签栏
Toolbar/ 顶部工具栏(导入文件 / 适应视图 / 时间轴自适应 / Y 轴自适应 / 选区放大 / 光标 / 合并·拆分·清空网格)
SignalPanel/ 左侧信号面板:按文件分组的信号树、可见性勾选、样式编辑
PlotView/ 波形绘图区:网格、时间轴、光标、缩放、拖拽、实验性功能面板
Utilities/ 工具与辅助模块(Inc/ + Src/)
Application/ ... (见上)
CsvTable/ CSV 流式解析(列优先)
DataModel/ 数据层:DataSet / DataFile / SignalData / 显示配置
PaintUtil/ GDI+ 绘制与文本/控件辅助(含 UiPointInRect、UiMeasureTextWidth 等)
PopupMenu/ 主题化自绘弹出菜单(含二级子菜单、内联编辑)
ColorPickerPopup/ 浮动拾色器
TextEdit/ 内联文本编辑模型
Theme/ 主题色板(Solarised Light)与波形默认配色
Utf8Util/ 编码转换(UTF-8 ↔ UTF-16 等)
Resources/ 图标、清单(.manifest)、版本信息(.rc)、ResourceIds.h
TestData/ 本地测试 CSV(**不纳入版本控制**)
Documents/ 项目文档(含本文件)
```
- **命名硬性约束**:源文件/头文件**不得含空格**,只能字母与下划线组合。
- **头文件后缀**:C 用 `.h`,C++ 用 `.hpp`;C 源 `.c`,C++ 源 `.cpp`。
- **防重复包含**:每个头文件必须 `#ifndef __XXX_XX_HPP__ / #define __XXX_XX_HPP__ / ... / #endif`;宏名 = 文件名的双边双下划线 + 全大写。
- **宏定义与枚举常量必须全大写 + 下划线**。
- 所有对外函数/方法声明用 Doxygen 风格注释:`@brief`、`@param [IN/OUT]`、`@return`(中文说明)。
- 代码风格统一,强调**解耦**与清晰的分层。
---
## 4. 架构要点
- **数据层共享、视图配置分标签页**:全局 `DataSet`(已加载文件、信号、时间轴)被所有标签页共享;每个标签页仅持有各自的 `TabConfig`(信号可见性、配色、分组、网格等),互不影响。
- `Application` 是总编排者:创建窗口、`Relayout` 布局、`HandleMessage` 分发消息、合成绘制、把菜单/拾色器结果派发回组件。
- 自定义绘制的浮动控件(`PopupMenu`、`ColorPickerPopup`)**不创建独立窗口**,作为宿主绘制的**最上层内容**直接画进后备缓冲区,保证外观与主界面统一(不出现系统默认菜单样式)。
- 消息优先级:菜单打开 > 拾色器打开 > 常规组件交互(见 `Application::HandleMessage`)。
- DPI 感知:`ApplyDpi` 重建字体与布局;浮动控件通过 `SetScale` 缩放。
### 数据模型(`Utilities/Inc/DataModel.hpp`)
- `SignalData`:单条信号元数据 + 采样值。
- `DataFile`:一个已加载文件及其**自有时间轴**(`timeColumnName` / `time` / `minTime` / `maxTime` / `timeMonotonic`)。
- `DataSet`:文件集合与全局时间范围查询(`MinTime/MaxTime`、最近采样点查找等)。
- `DrawMode`:`LINE`(插值折线) / `SCATTER`(散点) / `LINE_SCATTER`(两者)。
- `LaneMode`:`PER_SIGNAL` / `MERGED` / `CUSTOM`(网格划分策略)。
- `PlotCursorMode`:`NONE` / `SINGLE` / `DOUBLE`(时间光标三态)。
- 各网格自身状态(Y 轴范围、自适应开关、实验性功能开关等)随信号继承。
### CSV 解析(`Utilities/Src/DataModel.cpp`)
- 列优先存储(每条信号一整块连续内存):避免百万行级按行存储的堆分配放大,并支持整列**移动**进 `SignalData`。
- 流式分块读取,可携带真实字节进度(`CsvProgressInfo`),支持取消。
- **时间列判定** `LooksLikeTimeHeader`:小写匹配 `t / time / timestamp / simulation time`,或包含 `time` / `时间`;未命中时回退第 0 列。
- 缺失值以 `NaN` 表示;要求时间列有效数值 ≥ 2,否则报错。
- 分隔符自动探测(默认 `,`)。
---
## 5. 功能需求清单(来自需求交互,持续补充)
### 5.1 基础功能(首轮构建目标,已实现)
- 波形网格(多网格);时间轴。
- 光标(单/双光标,含差值读数)。
- 缩放与自适应(适应视图、Y 轴自适应、选区放大)。
- 导入并加载符合要求(带时间轴列)的 CSV,**解析出信号名**。
- 信号样式:**修改颜色、粗细、散点/插值线段显示方式**(`DrawMode`)。
- 信号可绘制到**同一网格**或**不同网格**。
### 5.2 后续扩展(已在代码中实现,供接手参考)
- 多标签页(新建/关闭/重命名/拖拽排序)。
- 工具栏:合并网格 / 拆分网格 / 清空网格;时间轴自适应。
- 信号面板:按文件分组、勾选可见性、仅显示/全部显示/隐藏全部、移除文件。
- 绘图区拖拽:平移、选区放大(按首个明显位移方向决定横/纵轴)、网格滚动、光标拖拽、**把信号曲线拖到其它网格**、网格图例 GX 徽标上下重排。
- 图例:悬停高亮、内联重命名、信号删除按钮。
### 5.3 「实验性功能」二级菜单(网格右键菜单内,横线上方)
包含且仅包含以下可勾选项(每网格独立状态):
- **边沿计数**
- **边沿计数(中心轴)**
- **均值滤波**
- **弱波增强**
这些功能开关会联动绘图区的浮动参数面板;部分支持「构建为新信号」。
> 注意:新增/改名实验性功能时,需同步 `PlotView.hpp` 的 `PLOT_CMD_LANE_*`、`Application.cpp` 的菜单构建与派发、`PlotView`/`DataModel` 的状态位与面板绘制。
---
## 6. UI / 交互设计语言
- 参考 **VS Code** 与 **WinUI** 的设计语言;配色为 **VS Code 的 Solarised Light** 方案(`Theme::SolarizedLight()`,强调色 `accent`)。
- 全部界面与交互用 **Win32 API** 自绘,不用系统默认控件样式。
- 文本对齐、菜单内边距、圆角、阴影等在各浮动控件内保持统一风格。
---
## 7. 交付与 Git 规范
- 工作区由**本地 git** 管辖。
- **仅在功能完善(可验证)后才提交**;提交信息用简洁中文描述修改内容。
- 提交时**仅对本工作区**生效(`git config --local`),**不得影响全局设置**:
- `user.name` = 当前模型全称。
- `user.email` = `<>`(留空尖括号)。
- `TestData/`、`Build/` 等不提交(见 `.gitignore`)。
---
## 8. Agent 工作流要求
1. 接手前先**探索工作区/提交历史/代码**建立认知(本文件应作为第一参考)。
2. 完成需求后**编译验证**(`make release` 至少成功),必要时用 `TestData/` 数据功能验证。
3. **验证与探索时尽量不占用用户的鼠标/屏幕/键盘**(后台构建为主)。
4. 本地仓库提交完成后,**不要在未经指示的情况下继续探索或优化**,等待用户下一步指令。
5. **每次完成任务后更新本文件**(需求、修复、约定变更)。
---
## 9. 已知问题与修复记录
### 9.1 网格右键菜单「实验性功能」二级菜单瞬间关闭(已修复)
- **症状**:在网格右键菜单里把鼠标从「实验性功能」移向右侧二级菜单的途中,只要指针短暂离开父项交互区域,二级菜单立即关闭,用户来不及完成选择。旧设备 CPU 较慢、鼠标移动事件稀疏,指针可能"跳"进二级菜单而未暴露该问题;CPU 更快、事件更密集时必然复现。
- **根因**(`Utilities/Src/PopupMenu.cpp` 的 `PopupMenu::OnMouseMove`):
- 子菜单展开后,只要指针**不在子菜单矩形内**且**未命中任何可点击条目**,就无条件 `CloseSubmenu()`。
- 主菜单条目矩形的右缘为 `menu.right - kPadH`,而子菜单左缘为 `menu.right - kSubOverlap`,两者之间存在约 3px 的**内边距"死区"**(位于主菜单矩形内但落在任何条目之外)。鼠标事件密集时指针必然落入该窄带,从而触发误关闭。
- **修复**:子菜单展开期间,若指针仍位于「主菜单 ∪ 子菜单」矩形范围内、且尚未落到**其它可点击条目**上(例如掠过内边距窄带、分隔线、禁用项),则保持子菜单展开并维持父项高亮;只有明确移动到其它条目或完全离开上述范围时才收起。
### 9.2 打开网格右键菜单后,悬停光晕与「可拖拽」提示气泡不消失(已修复)
- **症状**:鼠标停在信号曲线上(出现光晕高亮 + 「XXX · 按住拖拽可移入其它网格」提示气泡)时右键弹出网格菜单,高亮与气泡**残留在原地**,不随菜单打开而消失。
- **根因**:菜单打开后鼠标移动被菜单独占(`Application::HandleMessage` 在 `m_menu.IsOpen()` 时只把事件交给菜单),绘图区再也收不到 `WM_MOUSEMOVE`;而打开菜单时**没有清空**绘图区的悬停状态(`m_hoverSignal` / `m_hoverLegend`),因此 `PaintHoverHighlight` 与 `PaintSignalInteraction` 继续把高亮与气泡画出来。
- **修复**:
- `Application` 在绘图区右键弹出网格菜单前调用 `m_plot.OnMouseLeave()`,清空悬停状态;
- `PlotView::OnMouseLeave()` 补上清空 `m_hoverLegend`(原先只清 `m_hoverLane/m_hoverSignal` 等,图例半高亮会残留)。
### 9.3 性能:启用带预览曲线的实验性功能后,平移/拖动光标异常卡顿(已修复)
- **症状**(以 `VERY_BIG_DATA.CSV` 为例,加载并显示全部信号后):时间轴选区放大到某些区间时,左键/中键平移网格视界、或启用「均值滤波 / 中心轴边沿计数」后再平移、甚至只是左右拖动时间光标看数值,都会**异常卡顿**(实测单帧 300ms+)。
- **排查方法**:在 `.preflight/perf/`(gitignored)用离屏窗口(不占用鼠标/键盘/屏幕)直接驱动 `PlotView`,用 `QueryPerformanceCounter` 测 `Paint()`,并对 `CurveSpanFor` / `ComputeMovingAverageIterated` / `PaintDerivedCurve` 分段埋点计时。
- **根因**(`Components/PlotView/Src/PlotView.cpp` 的 `PaintCursor` → `paintDerivedCapsule` lambda):
- 绘制时间光标处的**派生曲线取值胶囊**时,把请求窗口取成「**可见区间 ∪ 光标采样下标**」的并集;
- 当光标不在当前视界内(平移视界会把光标甩到视界外)时,这个并集会变成 `[光标下标, 视界右端]` 或 `[视界左端, 光标下标]` —— 上千万采样的**巨大窗口**;
- 该窗口超过 `kCurveSpanCacheMaxSamples`(4M) → 走一次性缓冲、**不进缓存** → **每一帧都重算整段多次迭代滑动平均**(实测 5 条曲线合计 300ms+)。
- 这条路径同样被平移、拖滑块、拖动光标、改色等所有「每帧重绘」的交互触发,因此它们一起变卡。
- **修复**:`paintDerivedCapsule` 只需要光标所在采样点(以及双光标另一端)的派生取值,因此把请求窗口收窄为「**需要的那几个下标**」(`[sampleIndex, sampleIndex+1]`,双光标时并入另一端)。均值滤波半径造成的取值依赖由 `CurveSpanFor` 自身的保护带(`guard = clamp(max(窗口/8, (X+1)*N+64), 4096, 65536)`)负责覆盖 —— 对一切 `reach ≤ 65536` 的参数,取值与「整条曲线」**逐位一致**,显示效果零变化。
- **效果**(离屏实测,`VERY_BIG_DATA`,5 网格):
- 均值滤波平移(2% 跨度):**326ms → 约 16–23ms**;
- 均值滤波平移(0.4%/0.08%/0.016%):**约 305ms → 4–6ms**;
- 中心轴边沿计数平移(2%):**约 300ms → 约 20ms**;
- 光标左右移动(静态场景):命中场景缓存,仅叠加层重绘,约 9ms。
- **不影响显示的说明**:本次**未**改动任何几何/绘制算法(没有引入曾导致"棘状细节被并成实心块"的 ±1 列扩展),只收窄了「为了取时间光标处派生取值而请求的曲线窗口」,因此画面与实际取值完全不变。
### 9.4 性能经验与已知残留热点(供后续参考)
- **本仓库的性能黄金法则**:交互期间(`m_dragMode != NONE`)走「快速帧」(关闭波形/派生曲线抗锯齿),松手后补一帧全质量;静止画面走「静态场景缓存」(BitBlt 贴图 ~1ms)。任何**只在鼠标移动/拖动参数时改变**的内容都必须画在贴图之后(叠加层),绝不能进静态缓存,也不能每帧触发整条曲线/整段采样的重算。
- **已知残留热点**(本次未动,均为"非拖拽的整幅重绘",实际拖拽时因快速帧而很快):
- 原始波形的「逐像素列包络带」用 GDI+ `FillPath` 填充;在某些跨度下包络带不被判为「梳状」,抗锯齿填充仍约 20–27ms/帧(`VERY_BIG_DATA` 的全量重绘)。这是 GDI+ 抗锯齿路径的固有代价;如需进一步提速,**只能**降低列分辨率或对该形态关闭抗锯齿 —— 两者都会改变显示效果,需用户确认后再动。
- 「构建为新信号」等一次性操作会真正计算整条派生曲线(约 1 帧的秒级耗时,正常)。
- **测量工具**:`.preflight/perf/perf.exe <csv> [mode]`(离屏,不弹窗)。`mode=0` 跑各交互场景耗时;`mode=1` 导出确定性渲染 PNG 以便逐像素比对(用于验证"显示效果不变")。该目录已由 `.gitignore` 忽略,不会入库。
### 9.5 性能:启用带预览曲线的实验性功能后,交互严重卡顿(核心修复,含"为什么只有某些缩放等级卡"的解答)
- **症状**(以 `VERY_BIG_DATA.CSV`,5 信号全部显示为例):
- 「弱波增强」滑块**拖动时**在**任何缩放等级**下都卡顿;越缩得小(可见时间跨度越大)越卡 —— 时间跨度到 2.5s 时单帧 ≈89ms,完全放大(整段 128.8s)时单帧 **≈2.3 秒**。
- 网格「选区放大」到某些跨度后,平移视界 / 改样式也卡。
- **根因**(`Components/PlotView/Src/PlotView.cpp`,`CurveSpanFor` + 缓存策略 + `Utilities/Src/DataModel.cpp` 的 `ComputeWeakWaveBoost`):
1. 派生(预览)曲线按「可见采样窗口 ± 保护带」计算,**代价正比于窗口内的采样数**。实测:弱波增强整段(2576 万采样)单条 **≈368ms**,均值滤波整段单条 ≈57ms。5 个网格就是 ≈1.8s。
2. 旧代码有一条「**超过 4M 采样的窗口不进缓存**」的规则(为省内存),于是**大跨度下每帧都重算**整段曲线 —— 包括静止时的鼠标悬停重绘。这就是"某些缩放等级特别卡"的原因:
- 完全放大:窗口 = 整条信号(千万级)→ 每帧重算 → 秒级卡顿;
- 很小的时间跨度:窗口只有几千采样 → 计算只要微秒级 → **毫不卡顿**;
- 中间跨度:窗口在十几万~几百万采样之间 → 单帧几十毫秒 → 卡,且保护带(≤65536)相对窗口太短,平移很容易越出缓存覆盖 → 频繁重算。
3. 拖动「弱波增强」滑块每帧都改等级 → 缓存键变化 → 每帧都命中"未缓存"→ 每帧重算(叠加上述放大效应)。
- **修复**(三管齐下,**显示效果逐像素不变**):
1. **大窗口也进缓存**:`kCurveSpanCacheMaxSamples` 提高到 64M(相当于不再按采样数排除),并新增**总字节预算** `kCurveSpanCacheMaxBytes`(≈768MB,最旧优先淘汰)兜底内存。这样同一参数下的平移/缩放/悬停都能复用,静止重绘降到 **0.25ms**。
2. **交互期间「延迟重算」**(`CurveSpanFor` 新增 `allowInteractiveDefer`):拖动滑块/移动视界/缩放时,若所需窗口超过 `kDerivedInteractiveComputeBudget`(131072 采样),**本帧不重算**,改复用上一次算好的曲线(曲线下标→时间的映射仍是当前视图,因此位置准确),交互结束后再按最终参数精确重算。窗口不大时(常见工作缩放)照常同步重算,预览曲线**实时跟随控件**。
- 用途区分:**预览绘制**(`PaintMeanFilterCurve`/`PaintWeakBoostCurve`/光标取值胶囊)传 `allowInteractiveDefer = true`;**需要精确取值的统计**(中心轴边沿计数 `EdgeCenterCountsFor`、`MeanFilterValueAt`/`WeakBoostValueAt`)传 `false`(默认)。
3. **计算缓冲复用**:`ComputeWeakWaveBoost` / `ComputeMovingAverageIterated` / `ApplyWeakBoostStage` 的工作缓冲改为 `thread_local` 复用容量,避免每次重算都分配并零初始化上千万元素(多阶弱波增强 991ms→577ms)。
- **实测**(离屏,`VERY_BIG_DATA`,5 网格;单位 ms/帧):
| 场景 | 修复前 | 修复后 |
| --- | --- | --- |
| 弱波增强 滑块拖动(整段) | ≈2303 | **≈18** |
| 弱波增强 滑块拖动(2.5s 跨度) | ≈89 | **≈15** |
| 弱波增强 滑块拖动(103ms / 20ms 跨度) | ≈14 / ≈12 | ≈15 / ≈11 |
| 弱波增强 静止重绘(整段) | ≈2247 | **≈0.25** |
| 均值滤波 平移(2.5s 跨度) | ≈27 | ≈23(大窗口非拖拽时仍按需重算;拖拽时延迟) |
| 普通平移 / 光标移动 / 改色 | 3–12 | 不变(1–12) |
- **已知残留**(不牺牲显示时的固有成本):把可见时间跨度拉到接近整段且开启弱波增强时,**首次**计算某参数/窗口的预览曲线仍需要一次 ≈1.8s(5 条整段曲线,各 ≈368ms)—— 交互期间不会触发(已延迟),但交互结束后的那一帧会补上。这是"整段弱波增强 O(n) 计算"的固有代价;若要进一步消除需把该计算移到后台线程(尚未做,改动较大)。
- **为什么这样改不牺牲显示效果**:本次只改「何时计算 / 是否缓存」,不改任何几何或取值算法;`mode=1` 的逐像素比对(均值滤波 + 弱波增强,两个缩放级别,含光标取值胶囊)结果为 **0 像素差异**。
### 9.6 架构:派生曲线计算与 UI 绘制分离(后台线程 + 双缓冲)
- **动机/结论**:界面卡顿的根因是「**重计算发生在绘制线程里**」(`PlotView::Paint` 内同步算派生曲线)。只要某次 `Paint` 要算整段弱波增强(单条 ≈368ms),消息循环就被阻塞,滑块/光标/视界全部失去跟手。因此把**预览曲线的重计算移到后台线程**,绘制线程永不等计算。
- **实现**(`Components/PlotView/`,全部改动仅在 `PlotView.hpp/.cpp` 与宿主 `Application.cpp`):
- **后台工作线程**:`m_curveWorker` + 任务队列(`m_curveJobs`)+ `condition_variable`(惰性启动、析构/结构变化时 `StopCurveWorker` join)。线程只读采样、在锁外计算,算完把结果作为**不可变条目**(`shared_ptr<const CurveEntry>`)追加进 `m_curveEntries`,再 `PostMessage(kMessagePlotDerivedReady)` 通知宿主重绘。
- **双缓冲**:`CurveSpanFor` 命中覆盖区间的条目就直接用;未命中且**窗口大**(> `kDerivedAsyncWindowThreshold` = 65536 采样)时**本帧不计算**,改用该曲线**最新的旧条目**顶替(`AcquireNewestCurve`),同时排一个后台任务;窗口**小**(≤门槛)仍同步计算以保证预览**实时跟随**滑块。条目用 `shared_ptr` 共享、绘制线程取到后钉进 `m_curvePins` 保持整帧有效,后台只追加不改写 → 无数据竞争。
- **一致性与生命周期**:后台发布新条目后置 `m_curveSceneDirty`,下一帧作废静态场景缓存以纳入新曲线;**任何会改变数据层结构(增删文件/信号、插入派生信号)的操作前**,宿主先调用 `PlotView::PauseDerivedAsync()`(join 后台线程 + 清队列/缓存 + 自增代次),杜绝后台线程读取已释放/已移动的采样。`InvalidateDerivedCaches()` 内部也会 `PauseDerivedAsync()`。
- 精确取值路径(中心轴边沿计数 `EdgeCenterCountsFor`、取值胶囊需要精确值时)传 `allowAsyncCompute = false`,走同步计算。
- **实测**(离屏,`VERY_BIG_DATA`,5 网格;单位 ms/帧):弱波增强平移(整段)**2247 → 15**;弱波增强拖滑块(整段)**2303 → 15**;弱波增强静止重绘 **0.25**;后台结算后的全质量帧 ≈19ms/帧(一次性)。所有交互均 ≤ ~30ms,**不再有 300ms/2s 级尖峰**。
- **显示效果**:小窗口(同步)路径与旧实现**逐像素 0 差异**;大窗口(异步)路径的曲线像素与旧实现一致(差异仅出现在合成测试中一个与缓存相关的**旧实现的时轴陈旧**现象上,新实现按设定视图正确绘制)。后台完成后的全质量帧与同步结果相同。
- **代价**:大窗口缓存条目可占用较多内存(整段弱波增强单条 ≈104MB/信号),由 `kCurveSpanCacheMaxBytes`(768MB)+ 条目数上限(32)兜底,最旧优先淘汰。
### 9.7 完善异步架构 + 预览曲线层级修正(本仓库当前状态)
在 9.6 基础上做了三项关键完善:
1. **后台线程池(真·多线程)**:原先是**单线程**顺序计算,5 个网格各一条整段弱波增强要 5×368ms≈1.8s,用户会长时间看不到预览曲线(看起来"完全没画出来")。改为 **`m_curveWorkers` 线程池**(`EnsureCurveWorker`,线程数 = `clamp(硬件并发-1, 1, 4)`,给 UI 留一个核),多条曲线**并行**计算,预览曲线因此很快出现。`StopCurveWorker` 会 join 全部线程。**后台线程优先级设为 `THREAD_PRIORITY_BELOW_NORMAL`**,保证拖动时 UI/绘制线程始终优先获得 CPU(计算只在 UI 空闲时占用),避免"多线程反而抢核导致卡顿"。
2. **预览曲线绘制在「本网格全部原信号之上」**:原先在**同一个信号循环里**就地叠加派生曲线,导致网格内有多条信号时,后绘制的原信号会把先前信号的预览曲线盖住(用户明确要求"预览信号处于原信号上层不要被覆盖")。现在 `PaintLane` 分**两遍**:第一遍画全部原信号(线/散点/光晕),第二遍画全部派生预览曲线。
3. **快速帧(关抗锯齿)只用于「会改变画面主体」的拖拽**:原先 `m_fastFrame = (拖拽中)`,会给静态场景缓存打上「已降级」标记 → 缓存失效 → 每帧整幅重画。像**拖时间光标 / 选区框选 / 拖浮动面板**这些**只改叠加层、主体不变**的交互因此被拖慢(实测拖光标从约 1ms 升到十几毫秒)。现在只有 `PAN / SCROLL_LANES / LANE_REORDER / MOVE_SIGNAL / BOOST_LEVEL / BOOST_ITERATIONS` 才算快速帧;其余拖拽直接复用静态场景缓存(贴图 ~1ms)+ 叠加层。实测拖光标 **全程命中缓存、约 1–4ms**。
- **平移(尤其"一大格=2ms")的固有下限**:平移会让视图每帧变化 → 静态场景缓存必然失效 → 每帧都要重画波形(**这部分无法异步**,因为 GDI+ 绘制必须在 UI 线程;异步只覆盖"派生预览曲线"的计算)。在 5 个网格、含 2576 万采样编码器方波的 `VERY_BIG_DATA` 上,实测**拖拽平移**约 6–15ms/帧(机器空闲/繁忙分别为下限/上限),已无尖峰。若日后要进一步降低,只能减少波形绘制本身(受"不牺牲显示效果"约束)或把整帧渲染也搬到离屏线程(改动很大、风险高)。
- **预览曲线"看起来没画出来"的真相**:对**编码器方波这类强信号**,弱波增强几乎不改动波形,所以预览曲线与原信号**几乎重合**(读数胶囊里两者数值只差末位),视觉上"看不见"——这是**正确行为**。用 `mode=1` 导出可确认曲线确实被绘制(G1–G5 均有浅色叠加 +「(弱波增强)」读数)。小跨度渲染与旧实现**逐像素 0 差异**。
### 9.8 预览曲线严格等同「构建为新信号」+ 平移装饰层缓存(本仓库当前状态)
**(一)预览曲线与「构建为新信号」逐位一致**
- 原实现按「可见区间 ± 保护带」计算预览曲线(近似,边界可能略有差异)。用户明确要求"预览显示效果要和构建为新信号一致"。
- 现改为**一律按整条信号计算**(`PaintMeanFilterCurve`/`PaintWeakBoostCurve` 传 `begin=0, end=count`):与 `BuildDerivedSignal` 调用的是**同一个** `ComputeWeakWaveBoost`/`ComputeMovingAverageIterated`、同一段 `values`,因此**逐位一致**。整段窗口超过异步门槛时由后台线程计算(绘制线程不阻塞)。
**(二)平移期间的「装饰层」像素缓存(`m_decorDc`)**
- 实测(`VERY_BIG_DATA`,2ms/大格)整幅重绘约 10ms,其中 **图例 ≈3.5ms、Y 轴刻度 ≈1.5ms**(文字 + 圆角底板),二者合计过半;而它们**只随网格几何/信号外观变化,与时间轴视图无关**(平移时不变)。
- 实现:每张"全质量帧"后,把**图例带**(编号徽标 + 底板 + 色块 + 文字)与**Y 轴刻度胶囊**的像素从后备缓冲区捕获进 `m_decorDc`;拖拽(快速帧)时**跳过重绘**、直接 `BitBlt` 贴回。用专用指纹 `SceneDecorHash()`(含外观样式、**不含派生曲线参数**)校验,内容一旦变化即失效并重新捕获,绝不使用过期像素。仅捕获**装饰矩形本身**(逐胶囊 + 图例带),矩形之间的波形照常绘制,因此**无错位/无鬼影**。
- 效果:拖拽平移(2ms/大格)整帧 **13ms → 约 5ms**;0.4% 跨度 20→8ms。**注意**:该缓存只作用于「拖拽(快速帧)」——静止帧仍按全质量重绘装饰(保证最终画质)。
- 拖弱波增强滑块时,`SceneDecorHash` 不含派生参数 → 装饰层不会因滑块每帧变参数而失效(否则拖滑块要重绘图例/刻度)。
**(三)后台任务去重(避免拖滑块淹没线程池)**
- 拖滑块每帧改参数 → 若每帧都入队"整条曲线"任务,会灌满队列、后台线程池被淹没并与 UI 抢 CPU。现按「信号 + 曲线种类」只保留**一个**待算任务、**以最新参数为准**(旧的待算任务被移除;正在计算的那个结果由后续条目取代)。
**(四)后台线程优先级**
- 见 9.7 第 3 点:后台线程为 `THREAD_PRIORITY_BELOW_NORMAL`,UI 需要运行时永远优先获得 CPU。
**(五)平移仍有固有下限(诚实说明)**
- 平移**必然**让视图每帧变化 → 静态场景缓存失效 → 波形必须在 UI 线程重画(GDI+ 只能在 UI 线程绘制,**无法异步**)。装饰层缓存已把"与视图无关"的部分(图例/刻度)从每帧开销中移除,剩余开销是波形本身 + 网格线 + 时间轴,属"不牺牲显示效果"下的固有成本。
**(六)离屏逐像素校验(含历史踩坑提醒)**
- 工具:`.preflight/perf/perf_<ver>.exe <csv> 1 <prefix>` 导出确定性 PNG;`.preflight/perf/compare.ps1` 用 `LockBits`+`Marshal.Copy` 逐字节比对。
- **踩坑**:比对若用 `[System.Drawing.Bitmap]::GetPixel` 且两侧位图**尺寸不一致或文件缺失**,会抛 `调用"GetPixel"时发生异常:"参数无效"`。根因通常是"旧版本"的 harness 链接失败(例如它引用了新版本才有的符号)导致对照图没生成。**务必**:①先确认两侧 PNG 都存在且尺寸一致;②用 `LockBits` 而非逐点 `GetPixel`(后者对上兆像素级图片极慢)。
- **实测结论**(`709cbe4` 的"可见窗口预览" vs `389b4ec` 的"整段预览",同 harness 同数据):全部**全质量帧**(均值滤波/弱波增强 × FitAll/2.5s/103ms 三个缩放)**逐像素 0 差异** —— 证明"改为整段计算"未带来任何显示回归,预览与"构建为新信号"一致;仅**拖拽中途**帧有 0.09% 差异(快速帧关抗锯齿 + 装饰层贴回的预期差异)。
### 9.9 深度定位:样式/参数滑块的卡顿根因与修复(探针 + 分支计时)
- **用户复现**:`VERY_BIG_DATA` 加载全部信号 → 选区放大到视界 `[40.1756, 40.1885]s`(Δt≈12.9ms,≈2 采样/像素)→ 拖左/中键平移、拖透明度/粗细/自定义颜色/拾色、拖弱波增强参数滑块 → 极卡;但**拖时间光标很流畅**(光标只改叠加层、命中静态场景缓存)。
- **定位方法**(临时探针,`JDI_PERF_PROBE` 门控,事后已移除):在 `.preflight/perf` 的离屏 harness 中**精确复现该视界**(`mode=2`),给 `PlotView::Paint` 的每个分支(缓存贴图/样式补丁/整幅重绘/网格重排)与 `PaintLane` 的每个子阶段(网格线/波形/派生曲线/Y 轴刻度/图例/包络带 `FillPath`)加计时。
- **实测拆解**(本机当时负载约 4×,下列为负载下的值):
| 场景 | 整帧 | 其中包络带 `FillPath` |
| --- | --- | --- |
| 平移(整幅重绘,**抗锯齿开**) | 161ms | **143ms(5 网格,≈29ms/条)** |
| 平移拖动(快速帧,**抗锯齿关**) | 34ms | 27ms(5 网格,≈5.4ms/条) |
| 透明度/粗细/颜色(样式补丁,**抗锯齿开**) | **77ms** | **72ms(1 网格)** |
| 弱波增强参数滑块 | 96–176ms | (含整幅重绘放大) |
- **两个真正的根因**:
1. **样式补丁用了抗锯齿**:拖透明度/粗细/自定义颜色属于"样式变化 → 局部补丁",但它**不在任何拖拽态**,于是 `m_fastFrame=false` → 补丁用**抗锯齿**重画该网格的包络带(≈72ms/网格)。这是"样式滑块极卡"的直接原因。
2. **后台派生曲线发布时整幅作废**:拖弱波增强/均值滤波参数滑块时,后台每算完一条就 `InvalidateSceneCache()` → 下一步退化成**整幅重绘**(负载下 150ms+),而不只是补丁。
- **修复(3 处,均只影响拖拽帧;静止/结算帧逐像素不变)**:
- **样式补丁强制快速帧**:进入补丁分支时设 `m_fastFrame = true`(关抗锯齿)并置 `m_sceneDegraded = true` → 补丁 ≈72ms → **≈14ms**;参数稳定后的下一帧自动补一帧全质量。
- **拖拽中不整幅作废**:后台曲线发布时,若 `m_dragMode != NONE`(拖拽中)只置 `m_sceneDegraded`,由每帧补丁直接取到最新曲线;仅静止时才整幅作废。弱波增强滑块 ≈96–176ms → **≈25ms**。
- **交互期间省略浮动面板投影**:面板的两张大面积半透明圆角投影在快速帧跳过,减少面板重绘开销。
- **结果(同一负载机器)**:透明度/粗细/颜色 **77ms → 16ms**;弱波增强滑块 **96–176ms → 26ms**;平移拖动 **34ms**(快速帧,空闲机 ≈8ms)。全质量/结算帧与修复前**逐像素 0 差异**(`LockBits` 对照验证)。
- **仍存在的固有下限**:整幅重绘中的**包络带 `FillPath`** 本身是 O(像素列数) 的 GDI+ 抗锯齿填充(≈29ms/网格/负载)。它只在"整幅重绘"或"结算帧"出现(平移快速帧已关抗锯齿 ≈5.4ms/网格)。若要在密集包络带上也关抗锯齿可再快 5×,但会轻微改变边缘观感,**需用户确认后再动**。
### 9.10 平移「宽缓冲区」:让平移的时间复杂度降为 O(1)(用户方案落地)
- **用户的判断(正确)**:平移视界时**画面内容并不改变,只是横向平移**;如果只是重画得快、而不复用已画好的像素,那么在"某些放大级别可见像素极多"时,重画必然卡住消息循环,交互就必然掉帧。正确做法是**复用已绘制的像素**,让前台始终只做 O(1) 的取用。
- **对现有代码的核对**:用户判断与仓库现状**基本一致** —— 之前的历次优化都是"加速绘制"(包络带 `FillPath`、装饰层、缓存),而**没有把"绘制"这一任务本身从交互帧里拆出去**(后台线程只算了派生曲线的数值,没有做波形光栅化)。因此一遇到"可见像素特别多"的放大级别(如 `[40.1756, 40.1885]s`),每帧重画就再次成为瓶颈。
- **可行性结论**:用户的"每网格双缓冲 + 后台填充 + 前台只贴已就绪缓冲"方向正确。要点补充:① GDI/GDI+ 绘制**可以在后台线程**进行(每个线程用**各自**的 `Bitmap`/`HDC`/`Graphics` 即可),所以后台光栅化可行;② 但本仓库的 `PaintLane` 会读写大量成员缓存(折线/曲线/图例命中/装饰矩形),直接搬到后台线程需要一次较大重构才能保证线程安全。③ 因此本次采用一个**风险更低、收益相同**的落地方式(见下)。
- **本次实现(平移 O(1))**:
- **宽缓冲区**:以当前视界为中心,向左右各多渲染**半个视界宽**,存成一张宽为 `2×视界宽` 的离屏位图(`m_wideDc`)。它用与正常绘制**完全相同**的 `PaintLane`/`PaintTimeAxis`(临时把横向映射"加宽"后渲染),因此像素与正常绘制一致;仅**跳过图例与 Y 轴刻度**(二者锚定网格左缘、随视界移动,改由已有的**装饰层缓存**贴回)。
- **平移 = 一次 BitBlt**:视界宽度不变、只是横向移动时,前台从宽缓冲区**截取对应横向子矩形**贴到后备缓冲区即可(O(1)),**完全不重画波形**。无论放大到多深,平移都与全局显示一样流畅。
- **边界重建**:当视界移出宽缓冲范围(或跨度/内容变化)时,才以新视界为中心**重建一次**(约占整幅重绘 2 倍,但只在每平移约半屏时发生一次)。
- **实测**(`VERY_BIG_DATA` 全信号,视界 `[40.1756, 40.1885]s`,5 网格;单位 ms/帧,负载机):
| 场景 | 修复前 | 修复后 |
| --- | --- | --- |
| 中键平移拖动(短距) | 34ms | **1.3ms** |
| 中键平移拖动(长距,含边界重建) | 34ms | **1.7ms**(峰值 50ms 为一次边界重建) |
| 透明度/粗细/颜色 | 16ms | 8ms |
| 弱波增强滑块 | 26–176ms | 约 15ms(空闲)/ 55ms(负载) |
- **显示效果**:**静止/结算帧与修复前逐像素 0 差异**(`LockBits` 对照);平移拖动帧为快速帧质量(关闭抗锯齿,与既有"拖动时低分辨率、松手补全质量"策略一致),松手后立即恢复全质量。
- **说明**:宽缓冲区重建目前在前台进行(每平移约半屏一次);若日后要连这一次重建也移出交互帧,需把 `PaintLane` 的成员缓存重构为可复制的渲染上下文再搬后台(较大改动)。弱波增强参数滑块仍有一次"重绘受影响网格 + 浮动面板"的开销(负载下约 55ms),是本轮未彻底消除的残留。
### 9.11 把「宽缓冲区」推广为**基底复用**:平移 + 拖参数滑块都降为 O(1)
承接 9.10,进一步把「宽缓冲区」从"只用于平移"推广为**通用基底复用**,彻底解决拖派生参数滑块的问题:
- **抽取派生曲线绘制**:把 `PaintLane` 里叠加派生预览曲线(均值滤波/中心轴/弱波增强)的"第二遍"抽成 `PaintLaneDerivedOverlays`。宽缓冲区渲染时**跳过派生曲线**,因此宽缓冲只存**基底**(网格/波形/时间轴,不含派生曲线与图例/Y 轴刻度)。
- **基底键与派生参数解耦**:宽缓冲的校验键改为 `SceneContentHash(true, false)`(含外观样式、**不含派生参数**)→ 拖弱波增强等级/迭代次数滑块**不会让基底失效**,可直接复用基底像素。
- **统一前台路径**:判定"网格结构未变(几何指纹一致)且逐信号外观样式未变"(即只有**视图**或**派生参数**变化)时 → 贴宽缓冲基底像素 + **只重画派生曲线叠加层** + 装饰层 → 前台 O(1),不重画波形。
- 外观样式(颜色/粗细/透明度/模式)变化仍走"样式补丁"(局部重画受影响网格)。
- **实测**(`VERY_BIG_DATA` 全信号,视界 `[40.1756, 40.1885]s`,5 网格;负载机 ms/帧):
| 场景 | 9.10 后 | 本次后 |
| --- | --- | --- |
| 中键平移拖动 | 1.3ms | **1.3ms** |
| 弱波增强滑块 | 55ms | **5.2ms** |
| 透明度/粗细/颜色 | 8ms | 8ms |
- **显示效果**:静止/结算帧与 HEAD **逐像素 0 差异**(`LockBits` 验证)。拖拽/拖参数帧为快速帧质量(关抗锯齿),松手后恢复全质量。
- **仍存在的残留**(如实记录):
1. **宽缓冲区重建**在视界移出缓冲范围(约每平移 1 屏一次)时于**前台**进行,负载下约 30–50ms 的**一次**尖峰。彻底消除需把"基底渲染"做成**不依赖成员状态的独立渲染器**再搬后台线程(`PaintLane` 会写成员缓存,直接后台化有数据竞争,属较大重构)。
2. **非拖拽的视界变化**(键盘方向键 / 工具栏)仍走整幅重绘(约 125ms);仅**鼠标左/中键拖动**走宽缓冲快路径。若需让键盘平移也快,需允许非拖拽帧复用"整数像素对齐"的宽缓冲(当前为避免 ≤0.5px 亚像素偏差而未启用)。
### 9.12 宽缓冲改为「滚动」:消除周期性重建尖峰(持续拖动恒 ≥60fps)
9.11 的残留 1(移出边界时整幅重建 2×视界,负载下约 30–50ms 的一次尖峰)已消除:
- **滚动式缓冲**:视界每帧移动 d 像素时,把缓冲内容**整体自贴图平移 `round(d)` 像素**,再**只补画新暴露的 `|round(d)|` 像素宽细条**。因此**每帧代价恒为 O(细条)**,不再有"移出边界就整幅重建"的周期性大尖峰。
- **细条渲染复用整条宽窗口的映射**:`RenderWideStrip` 令 `m_area` 只覆盖细条(GDI/GDI+ 裁剪落在细条),而 `m_plotRect` 仍是完整宽窗口 → 网格线与波形 x 坐标正确、刻度密度与主视界一致;仅新暴露的细条被重画。
- **亚像素误差有界**:用锚点 `m_wideAnchorMin` 记"视界左端当前对应缓冲中的时间",每帧按 `round((viewMin - anchor)*scale)` 平移并同步推进锚点,使"内容相对时间轴的偏移"始终 ≤0.5 像素。
- **缓冲只覆盖绘图区**(不含底部时间轴):时间轴由前台每帧按当前视图绘制,避免滚动细条把时间轴文字切成残字。
- **实测**(`VERY_BIG_DATA` 全信号,视界 `[40.1756, 40.1885]s`,5 网格):
| 场景 | 9.11 | 本次 |
| --- | --- | --- |
| 中键平移拖动(短距) | 1.3ms(含每屏一次 33ms 尖峰) | **约 6ms/帧,无尖峰** |
| 中键平移拖动(长距 975px) | 1.7ms(最大 33ms) | **约 6ms/帧,最大 10.5ms** |
| 弱波增强滑块 | 5.2ms | 6ms |
| 透明度/粗细/颜色 | 8ms | 8ms |
- **显示效果**:滚动平移帧截图核对无缝隙/无残字;**静止/结算帧与 HEAD 逐像素 0 差异**(`LockBits` 验证)。
- **剩余残留**:**每次"开始一次拖拽"的第一帧**需新建缓冲(2×视界基底,负载下约 38ms 的一次尖峰);持续拖动过程中无尖峰。要消除这一次性启动尖峰,需把基底渲染做成可后台的独立渲染器(同上,较大重构)。非拖拽视界变化仍整幅重绘。
### 9.13 消除"开始拖拽"的首帧尖峰:用场景缓存初始化基底 + 启用预览功能时预建基底
9.12 的残留(首帧需从零新建 2×视界基底,负载下约 38ms)已消除:
- **平移首帧用「上一帧静态场景缓存」初始化宽基底**(`InitWideBufferFromScene`):平移只是整体横向移动,场景缓存里的内容(含派生曲线/图例)**依然正确**,因此把它直接贴进宽基底中心即可,再滚动到当前视界 —— 首帧只需一次 BitBlt + 一条细条,**不再从零重画 2×视界**。仅当"派生预览曲线参数未变"时适用(拖这些参数时派生曲线会变,须从零重建基底)。
- **启用带预览的实验性功能时预建基底**:在 `SetLaneWeakBoost`/`SetLaneMeanFilter`/`SetLaneEdgeCenterCount(…, true)` 里预建宽基底,使**首次拖动这些参数滑块**时无需从零重建(避免一次性尖峰)。
- **实测**(`VERY_BIG_DATA` 全信号,视界 `[40.1756, 40.1885]s`,5 网格,负载机 ms/帧):
| 场景 | 9.12 | 本次 |
| --- | --- | --- |
| 中键平移拖动(短距) | 最大 35ms(首帧重建) | **最大 8.5ms** |
| 中键平移拖动(长距) | 最大 10.5ms | **最大 7.4ms** |
| 弱波增强滑块 | 最大 37.6ms(首帧重建) | **最大 10.8ms** |
| 透明度/粗细/颜色 | 8–16ms | 8–15ms |
- **结论**:用户报告的全部**鼠标**交互(左/中键拖动平移、样式滑块/拾色、弱波增强参数滑块)在该放大区间**全程 ≥60fps、无尖峰**;静止/结算帧与 HEAD **逐像素 0 差异**。
- **当时仍存在的残留**:**非拖拽的视界变化**(键盘方向键 / 工具栏,非用户报告的鼠标场景)仍走整幅重绘(约 125ms)—— 因其没有"拖拽结束"事件可用来触发一次精确的全质量重绘,若走宽缓冲快路径会留下 ≤0.5px 的亚像素偏差且无法"结算"回精确帧,故当时保持整幅重绘以保证像素精确。**该残留已在 9.14 中解决。**
---
### 9.14 非拖拽视界变化(键盘 / 工具栏)也走宽基底快路径 + "结算"帧保证像素精确
9.13 的残留(非拖拽视界变化 ≈125ms / 8fps)已消除:
- **宽基底判定去掉"必须在拖拽中"**:只要"网格几何指纹 + 逐信号外观样式 + 装饰层"未变(派生曲线参数变化也算),任何**视界变化**都可用宽基底,前台只贴图 + 画时间轴/派生叠加/装饰层 —— 键盘方向键、工具栏缩放平移帧降到 **≈8ms**。
- **"结算"帧(保证最终像素精确)**:宽基底是整数像素平移,实际视界位置带**≤0.5px** 亚像素偏差。因此在快路径帧里把"内容指纹 ⊕ 视界"揉成一个 `wideKey`;**若本帧的 `wideKey` 与上一条快路径帧相同 → 判定"视界已停止变化" → 放弃快路径,落到整幅重绘**(一次全质量精确帧),并清空 `m_wideLastValid`。该帧同时刷新静态场景缓存,之后所有帧命中缓存(≈1ms),不会反复重绘。
- **分支顺序有硬约束**:静态场景贴图 → **宽基底** → 样式补丁 → 整幅重绘。
* 宽基底必须在**静态场景贴图之后**:视界与样式都没变时(鼠标悬停移动)必须命中场景贴图,否则每两帧就会触发一次"结算整幅重绘",悬停反而变卡。
* 宽基底必须在**样式补丁之前**:派生预览曲线参数变化(拖弱波增强 / 均值滤波滑块)走宽基底比"逐网格补丁"更快且质量更高(补丁会关抗锯齿)。
- **宽基底必须与"同一视界的整幅渲染"同源**(否则平移后网格/锯齿与前台不一致):基底渲染时**强制全质量抗锯齿**(不受拖拽快速帧影响),且网格竖向刻度沿用**前台视界跨度**而非 2× 的宽窗口跨度(后者会让竖网格线减半)。
- **实测**(`VERY_BIG_DATA`,视界 `[40.1756, 40.1885]s`,ms/帧):
| 场景 | 9.13 | 本次 |
| --- | --- | --- |
| **非拖拽视界变化(键盘/工具栏)** | 最大 124.6ms(≈8fps) | **最大 8.6ms** |
| 中键平移拖动(短/长距) | 8.5 / 7.4ms | 9.3 / 14.6ms |
| 透明度 / 颜色滑块 | 8–16ms | 10.6 / 12.5ms |
| 弱波增强滑块 | 最大 10.8ms | **最大 7.5ms** |
| 粗细滑块 | 8–15ms | 40 帧中仅**首帧** 127ms(其余 7.7ms) |
- **粗细滑块那一帧 127ms 是"设计内"的**:样式连续变化期间走"样式补丁 + 已降级标记"(快),一旦样式**停止变化**(含"回到与缓存相同的值")就必须补一帧全质量 —— 该帧必然是整幅重绘。它是**用户松手后的唯一一帧**,不是持续卡顿。
- **保真度实测**(新增 `perf.exe … 5` 像素对比模式):快路径与"同一视界的整幅全质量渲染"对比 ——
* 视界几乎不动(<0.01px,不滚动)时:`meanAbs = 0.68/255`(逐像素几乎一致,证明"底座 + 贴图"管线本身忠实)。
* 平移约 17px 时:`meanAbs ≈ 13.5/255`(差异集中在**密波形**上,是 ≤0.5px 亚像素平移的必然结果,肉眼不可辨);停止平移后的"结算"帧回到**像素精确**。
- **质量回退检查**:`mean_0/1/2`、`boost_0/1/2`、`panref` 七张静态/结算帧与 HEAD **字节完全一致**(SHA256 相同)。
- **意外收获**:平移**中途**帧比 HEAD 更干净 —— HEAD 把静态场景(含**图例 / Y 轴刻度**)直接贴进宽基底,平移后这些像素随之移动,出现**重复的网格标题与刻度重影**(见对比 PNG);本次改动后同一场景不再出现重影。
---
### 9.15 修复「缩放后平移出现拖影/残留/错误元素」与深放大区间仍然卡顿(本轮)
#### 用户方案评估:与当前实现的差异
用户提出的目标是「每个网格各自维护双缓冲区 + 后台线程绘制 + 前台只做贴图/坐标偏移」。
与当前实现的差异与问题点:
| 维度 | 当前实现 | 用户设想 |
| --- | --- | --- |
| 像素复用粒度 | **全局一张**「宽基底」+ 场景缓存 + 装饰缓存 + 样式补丁(4 套机制叠加) | **每网格**一张信号图层(双缓冲) |
| 绘制线程 | 全部在 UI 线程;只有**派生预览曲线**在后台算 | 单网格绘制也搬到后台 |
| 失效判定 | 多个指纹(内容/几何/装饰)各自为政,**容易漏项**(本轮事实上踩了坑) | 每网格一个状态指纹 |
问题点(本轮实证,是本次卡顿与拖影的**真正根因**):
1. **`Tab().LaneScroll()`(网格纵向滚动量)没有参与任何内容指纹。**
它只改变各网格的绘制位置,却被所有指纹忽略 → 一旦滚动网格(滚轮 / 上下方向键 / 滚动条),
平移宽缓冲区会把**旧滚动位置**的像素当成当前内容贴回(位置错位、拖影、错误元素);
同时因为指纹没变,"结算"逻辑误以为视界已停 → **每两帧就触发一次整幅重绘(约 125ms)**,
表现为 8fps 级卡顿。这是用户报告的**两个症状的共同根因**。
2. **`FindStyleOnlyLanes()` 只看 `SceneGeometryHash()`,而该指纹刻意不含视界。**
于是视界已经变了却仍走"只重画受影响网格"的局部补丁,把缓存里**旧视界的画面**
(时间轴、其它网格、底色)留在屏上 → 拖影 / 残留。
3. **`InitWideBufferFromScene()` 把"上一帧的整幅画面"拷进宽基底**,而整幅画面里**含装饰**
(Y 轴刻度胶囊 + 图例),宽基底却不含 → 装饰像素随平移一起移动,在网格左缘之外
留下一排旧图例/刻度(重影)。
4. **装饰缓存的两个不变量被打破**:贴回时用的是**当前帧重建的**矩形列表(命中缓存时根本不会重建 → 为空);
并用**当前帧的** `m_area` 原点去推算源偏移(两帧原点可能不同)→ 表现为装饰消失 / 黑块 / 整体错位。
#### 本轮修复
- `SceneContentHash()` 计入 `Tab().LaneScroll()`(根本修复:滚动网格不再复用旧位置像素,
也不再误触发"结算")。
- `FindStyleOnlyLanes()` 增加**视图几何逐项校验**(视界 / 滚动 / 布局 / 缩放 / 拖拽光晕)后才允许局部补丁。
- 宽基底**不再用"含装饰的整幅画面"初始化**;`RebuildWideBuffer()` 以 `m_renderWide` 跳过装饰,
保证基底永远不含装饰。
- 宽路径的装饰改为**按当前视界重画**(新增 `m_renderDecorOnly` 复用与整幅渲染**完全相同**
的 `PaintLane` 代码路径,只跳过网格线与波形)。
- 装饰缓存修好两个不变量:**每次真正绘制装饰的帧都立即重新捕获**(不再只在全质量帧捕获),
且捕获时**同时保存矩形列表与坐标原点**,贴回时用保存的这份、按绝对坐标写入。
- 装饰底板改为**不透明**(原来是 94% / 91%):装饰像素因此与时间视界无关,可安全跨视界复用;
半透明底板会把旧视界的波形混进缓存像素,平移时在底板内部留下残影。
- 新增离屏诊断模式(`.preflight/perf/perf_main.cpp` 的 `mode=6`):复现"加载 → 缩放 → 平移/键盘/滚轮"
全部交互,逐帧计时,并把"当前屏"与"从零整幅渲染"做**逐像素对比 + 差异包围盒**。
这是本轮定位问题的关键工具(否则只能靠猜测)。
#### 实测(`VERY_BIG_DATA.CSV`,视界 `[64.3948, 64.4153]s` ≈ **2.045ms 一大格**,5 网格,ms/帧)
| 交互 | 修复前 | 修复后 | 像素差(对比从零整幅渲染) |
| --- | --- | --- | --- |
| 键盘左右方向键平移 | 11~19ms | **5.2ms** | 3705 通道(≤0.5px 亚像素偏移,见下) |
| 键盘上下方向键滚动网格 | 0.9ms(但错位/偶发 125ms) | **0.5ms** | **0** |
| 中键水平拖动 | 10~18ms | **5.2ms** | 357 通道 |
| 中键**纵向**拖动 | 14~19ms | **6.0ms** | 72 通道 |
| 左键水平拖动 | 9~17ms | **4.5ms** | 390 通道 |
| 左键**纵向**拖动 | 15~19ms | **6.1ms** | 72 通道 |
| 滚轮滚动网格 | 1.0ms | **0.6ms** | **0** |
| 缩放后再平移 | 9~17ms | **3.7ms**(缩放帧 6.8ms) | 495 通道 |
| 弱波增强预览 + 平移 | 15~18ms | **5.8ms** | 2264 通道(异步曲线发布时序) |
- **修复前的纵向拖动会产生 2 万+ 通道的残留**(黑块 / 标签残缺);修复后降到 72 通道(仅亚像素量化)。
- 剩余的几百通道差异集中在**密网格/密波形**上,是"宽基底按整数像素平移(≤0.5px 亚像素误差)"
的必然结果,肉眼不可辨;视界停下后的"结算"帧会回到**像素精确**。
#### 「每网格双缓冲 + 后台绘制」架构(已落地,本轮同批提交)
把原来**全局一张**「宽缓冲区」重构为用户提出的方案:
- **每网格一块「信号图层」**(`LaneLayer`):宽度 = 视界宽 ×3(左右各多画半屏),只含该网格基底
(底色 + 网格线 + 波形 + 派生曲线),**不含装饰**(Y 轴刻度 / 图例,它们锚定网格左缘、不随时间视界移动)。
- **双缓冲**:`m_layerFront`(前台只读)/ `m_layerBack`(后台填充)。后台画完整块后
`std::swap` 整体交换;**前台永远能拿到立即可用的像素**,后台正在绘制也不影响交互。
- **后台绘制**:`m_layerWorker` 线程(BELOW_NORMAL 优先级)按任务队列逐网格重画。
前台每帧只做:① 判断各网格图层是否就绪;② `BitsBlt` 把**就绪且跨度一致**的图层按坐标偏移贴到画面;
③ 画时间轴 / 派生叠加层 / 装饰 / 叠加层。**前台不再绘制任何波形**。
- **"未就绪就用旧数据应付"**:只要图层**跨度一致、能覆盖当前视界**,即使内容稍旧也照贴
(`LayersReadyForView()` 不比较内容指纹),内容由后台补画后交换 —— 于是样式/参数变化、
平移、滚动都不会退化成整幅重绘。只有"跨度变了 / 图层还没建"才落到整幅重绘一次以保证正确性。
- **不收敛保护**:若同一状态反复排任务仍与前台不一致(说明某参与指纹的状态没被正确还原),
超过 90 次即改为同步重建一次强行收敛,避免死循环。
- **前台优先**:后台在"前台最近 30ms 内绘制过"时主动让路(把任务放回队首,8ms 后再试),
避免与前台抢 `m_renderMutex` 反而更卡;交互停下后后台从容补画。
- **加锁顺序**:一律 `m_renderMutex` → `m_layerMutex`(后台与前台一致),避免 AB-BA 死锁。
- **渲染状态还原**:图层渲染会临时改写 `m_area` / `m_plotRect` / 视界 / 网格矩形,
结束后必须调用 `BuildLaneVisuals()` 重算 `m_laneContentHeight` 并还原 `LaneScroll` 与各网格幅值范围
—— 否则内容指纹与前台永远不一致 → 图层被判定"永远过期" → 死循环(本轮踩过这个坑)。
验证(离屏,`perf.exe ... 6`,视界 ≈ **2.045ms 一大格**):
| 交互 | 帧平均 | 像素差(对比从零整幅渲染) |
| --- | --- | --- |
| 键盘左右方向键平移 | **3.5ms** | 3705 通道(≤0.5px 亚像素) |
| 键盘上下方向键滚动网格 | 0.7ms | **0** |
| 中键水平拖动 / 左键水平拖动 | 3.3 / 1.8ms | 357 / **0** 通道 |
| 中键纵向 / 左键纵向拖动 | 5.7 / 5.5ms | 72 通道 |
| 滚轮滚动网格 | 1.9ms | **0** |
| 缩放后再平移 | 4.6ms(缩放帧 5.0ms) | 549 通道 |
| 弱波增强预览 + 平移 | 8.7ms | 3590 通道(异步曲线发布时序) |
- 「背景图层绘制次数」计数可直接证明**单网格基底的绘制确实发生在后台线程**:一轮诊断累计 **18~45 次**;
前台只在做位图搬运。
- 跨进程验证:`BackgroundLayerRenderCount()` 为公开只读接口,`perf.exe ... 6` 结束时会打印总量。
### 9.16 修复「垂直中键拖拽后再平移,网格内容纵向跳变 / 绘制错误」(本轮)
#### 症状与复现
`VERY_BIG_DATA.CSV`(5 信号全选)下,任意缩放等级都对同一根网格成立:
1. 对某网格(如最上层「编码器A相」)按中键**纵向**拖拽约半个网格高,让该网格幅值窗口上下平移;
2. **不松开**中键,只做**一点点水平位移**。
预期只有水平移动,实际该网格的波形**整体纵向错位**(最多 15px,视拖动落点而定),看起来像"纵向跳帧/抽搐"。
若改为"松开后再按下中键做同样的微小水平移动",同样复现。
#### 根因:横向补细条时静默重置了图层的纵向锚点
图层用 `LaneLayer::anchorYMax`(渲染时该网格的 `yMax`)描述**像素的纵向映射**;贴图偏移
`vOffset = round((anchorYMax - 当前 yMax) * pxPerValue)`。纵向拖动用 `kLayerRollQuantum = 16px` **量化**:
`|vOffset| < 16px` 时不滚动,交给贴图偏移处理 —— 于是拖动停下时图层里会残留一个 `< 16px` 的纵向偏移,
而 `anchorYMax != 当前 yMax`。
问题出在 `RenderLaneLayerRegion` 结束时**无条件** `layer.anchorYMax = 当前 yMax`。该赋值对
「整块重画」「纵向补条(整宽)」成立(那两种情况整块像素都对齐到当前映射),但对
**「横向补细条(整高)」不成立**:横向滚动只改一条竖带,其余像素仍在**原锚点**上。于是第一次水平移动
(哪怕只有 3px)就把残余偏移**静默清零**(`vOffset` 变成 0),而像素并没有上移 → 整个网格的波形
相对正确位置纵向错位恰好等于那个残余量。
#### 修法
`RenderLaneLayerRegion` 新增形参 `double anchorYMax`(本次像素所对应的纵向锚点):内部以它作为幅值映射
基准(`yMin = anchorYMax - laneSpan`)渲染,并在结束时写入 `layer.anchorYMax = anchorYMax`。各调用点:
- 整块重画 / 纵向补条 / 待重画补齐:传**当前** `yMax`(纵向补条传滚动后的 `layer.anchorYMax`,与已滚动像素同一映射);
- **横向补细条**:传 `layer.anchorYMax`(**保持不变**),细条因此与其余像素纵向同源,残余偏移继续由贴图偏移承担。
#### 验证(离屏,`.preflight/perf/pancheck`,窗口 `SW_HIDE` + 直接注入消息,不动鼠标/键盘/屏幕)
探针逐帧读出图层内部状态(`anchorYMax` / 当前 `yMax` / `vOffset`),并把持按钮帧与
**释放后的整幅全质量帧**做纵向互相关,报告该网格的最佳纵向位移 `dy`。
| 场景(`VERY_BIG_DATA`,缩放 0 / 8 / 26 步) | 修复前 | 修复后 |
| --- | --- | --- |
| A 纵向拖半个网格高 → 持按钮水平微移 | **dy=+15px**(lane0 差异 39245) | **dy=0**(差异 1220,仅已知 ≤0.5px 列相位残差) |
| B 仅纵向拖(对照) | dy=0 | dy=0 |
| C 仅水平拖(对照) | dy=0 | dy=0 |
| D 中间网格纵向+水平 | — | dy=+1(整像素量化残差) |
| E 长距水平拖(越过 ±半屏余量)后再微移 | — | dy=0 |
| F 斜向拖后再水平 | — | dy=0 |
| G 纵向拖 → 松开 → 再按下中键水平微移 | — | dy=0 |
- 修复前 A 场景 `H step 1` 起 `vOff` 从 `-15.00` 被清零为 `+0.00`;修复后全程保持 `-15.00`(贴图偏移继续生效)。
- `vOffset == 0` 时新逻辑与旧逻辑**完全等价**(横向补条传的锚点就是当前 `yMax`),因此既有的
「仅水平平移」「仅纵向平移」行为与像素结果不变(已由对照场景 B/C 覆盖)。
- `make clean && make all` 零警告。
### 9.17 交互流畅性三项修复:光标不再跟随视界 / 缩放首帧不再卡顿 / 超大信号拖预览不拖慢前台(本轮)
#### 一、光标不再跟随视界(用户要求)
用户报告:"启用光标时,移动视界导致光标超出视界范围,光标会被强制移动到视界边界;
我需要光标留在原位置而不是跟随视界移动。"
- 移除 `ClampCursorsAfterPan()`(含声明)及其在**平移路径**的三处调用:中键/左键拖拽平移、
`PanTimeByFraction`(方向键)、`OnMouseWheel(shift=true)`。
- `ClampCursorToView()` 保留,**只在真正拖拽光标时**使用(其内注释与头文件说明同步更新)。
- 现行为:平移/缩放视界时光标的**时间位置完全不变**,允许滑出可视区(读数贴在网格边缘显示)。
- 验证(`.preflight/perf/zoomcheck` mode 14):平移 20 帧(视界移到 `[257.6, 386.4]s`,光标时间
`38.643036` 已远在视口外)后 `CursorTime` **完全不变**(`unchanged=1`, `offViewport=1`);
Shift+滚轮平移同样不变。
#### 二、缩放首帧不再卡顿(用户猜测"必要像素量级飙升导致首帧卡住"——**证实**)
- 量化(`.preflight/perf/zoomcheck` mode 0,`VERY_BIG_DATA` 5 网格逐步放大,逐帧计时):
每次缩放变化后 **f1=70~80ms(宽跨度)**、f2=26~40ms,f3 起 0.5ms。
- 根因:跨度变化时 `Paint` 里的 `if (!LayersStructurallyReady()) RebuildWideBuffer();` 会
**同步重建全部 5 个网格图层**(70~80ms);随后一帧又做一次整幅"结算"(40ms)→ 单次缩放
合计约 110ms 的卡顿。
- 修法(用户方案:先用旧图拉伸占位、不卡前台):
1. **旧跨度像素拉伸占位**:`BlitWideBuffer` 遇到 `layer.span != 当前 span` **不再跳过**
(跳过会让网格在重画完成前空白),而是把旧图层覆盖的时间段 `[layer.wideMin, +layer.span]`
重新映射到当前视界,用一次 `StretchBlt`(`HALFTONE`)拉出**低分辨率但立即可用**的画面。
2. `Paint` **不再同步整批重建**:只有"图层数量与网格数量不符"(首次绘制 / 增删网格,
新增 `LayersAllocated()`)才 `RebuildWideBuffer()`;跨度 / 内容过期一律入队,由
`RenderPendingLaneRebuilds` 逐帧补齐(原逻辑遇到跨度不符会跳过该网格,现改为直接按**新跨度**重建)。
3. **只在视界稳定后**(`viewStopped`)才花预算做精细重绘:视界仍在变化的每一帧只贴拉伸占位图,
否则缩放序列的每一帧都会夹带一个网格的整块重建(宽跨度约 20ms)→ 持续掉帧。
- 实测:缩放后**首帧 70~80ms → ~10ms**;精细重绘分散到后续帧(宽跨度 ~20ms/帧,深跨度 ~13ms/帧)。
收敛后仍由"结算"整幅帧保证像素精确。
- ⚠️ **保留"结算"帧**(不可省):实测宽基底路径与从零整幅重绘**并非像素同源**
(图层 2448px 按 1px 列聚合 vs 整幅 1224px 列,列相位差对稠密脉冲信号放大成 17 万+ 像素差异),
因此必须有这一次全质量整幅重绘来收敛。
- 正确性 A/B(mode 13):缩放并收敛后的画面 vs 从零整幅渲染,改动前后**同为 3774 像素差异**
(预存在的列相位/装饰残差),**非本次引入**。
#### 三、超大信号拖预览滑块不再拖慢前台(部分改善,根因未完全定位)
- 量化(mode 7/10/12,深放大,单网格预览,模拟面板滑块拖拽 `m_dragMode=PEAK_LEVEL`):
- 无预览基线(mode 5):0.38ms/帧。
- 预览 + **参数不变**(mode 12):3.4ms/帧。
- 预览 + **参数每帧变化**(mode 10):11.8ms/帧;弱峰 10 迭代(mode 7):15ms/帧。
- **直接调用 `PaintLaneDerivedOverlays`(mode 9):0.375ms** —— 说明开销**不在曲线绘制**。
- 结论:开销来自"参数每帧变化"触发的**整条信号**(2576 万采样)预览曲线重算调度
(预览曲线按整条信号计算,以保证与「构建为新信号」逐位一致)。
- 缓解:新增 `kHugeSignalPreviewSamples = 4000000` —— 信号超过该规模且**正在拖拽**(`m_dragMode != NONE`)时,
不再每帧排队"整条信号"的重算,改为沿用旧曲线、松手后再算一次最新的(整条信号重算本来也追不上
滑块速度,"冻结"与"每帧重排"观感相同,后者却持续占用后台线程与内存带宽)。
实测 15.0 → 13.7ms(改善有限)。
- ⚠️ **未完全解决**:参数变化带来的 ~8ms/帧开销**尚未完全归因**(已排除绘制;"暂停后台线程"的
对照无效,因为 `EnsureCurveWorker()` 会在暂停后被重新拉起)。用户提议的"网格内按信号并行绘制 +
后台缓冲合成"仍是候选方案,但需先把渲染器改造成**无共享可变状态**(与 §11.2 第 5 条硬约束冲突),
留待后续评估。
#### 四、验证
- `.preflight/perf/pancheck`(平移 / 纵向,7 个场景):全部 `dy=0`(原"中间网格 +1px"也归零)。
- `.preflight/perf/zoomcheck`:mode 0(逐帧首帧耗时)、mode 13(缩放收敛,A/B 无回归)、
mode 14(光标不跟随视界)。
- `make clean && make all` 零警告。
### 9.18 深放大中键拖拽卡顿根因(逐点描边 AA 病态)+ 横向补条裁剪 + 并行网格图层(本轮)
用户报告:`VERY_BIG_DATA.CSV` 只显示**编码器A相**、选区放大到 `[10.2719, 10.2831] s`
(≈1.12ms 一大格)后,中键拖拽视界**非常非常卡顿**。用户同时要求:**做 B 与 C**
(B=拆分宽基底路径耗时定位瓶颈;C=解除"不允许多线程"限制并实现并行网格渲染)。
#### 一、B:宽基底路径耗时拆解(`.preflight/perf/dragperf` mode 1)
在该视界逐项计时:`RollWideBuffer`(补条重绘)**13.08ms** / `BlitWideBuffer` 0.50 /
`PaintTimeAxis` 0.78 / `PaintLaneDividers` 0.04 / `PaintLaneDerivedOverlays` 0.00 /
`CaptureDecorCache` 0.03 —— **瓶颈全在 `RollWideBuffer` 的"补条重绘"**。
#### 二、补条重绘为什么慢 + 裁剪修复
- 补条只覆盖图层位图的**一条窄竖带**(`m_area` 的 x 区间),但折线原本按 `m_plotRect`(整条宽窗口
=2× 视界)构建整段顶点并交给 GDI+ 描边 —— 即使只画窄带,`DrawLines` 仍逐段提交。
- 修法:新增 `m_stripRenderActive/m_stripX0/m_stripX1`,**横向补条时把折线采样区间裁剪到条带对应
x 范围**(两侧各留 `max(4, lineWidth+3)` 像素余量,避免接缝与抗锯齿溢出),并**旁路折线缓存**
(补条只构建片段,写进缓存会污染整段复用)。
- ⚠️ **裁剪只对"逐点描边"路径生效,且必须在"包络/描边"分支判定之后**:若先裁剪,`visibleCount`
会变成条带内采样数,可能改变路径判定 → 条带与整块渲染像素不一致(出现接缝)。
- 实测 `RollWideBuffer` **13.08ms → 1.73ms**;中键拖拽整帧 **13~24ms → ~3ms**。
- 正确性(`.preflight/perf/dragperf` mode 3):人为破坏一条竖带后用补条重绘,
与整块渲染逐像素比较 **0 差异**(条带左/中/右/宽条 4 种位置都是 0)。
#### 三、真正的病态根因:GDI+ 抗锯齿描边"满幅锯齿"(本轮最关键)
修掉补条后,**一旦拖拽超出预渲染余量**(`RollWideBuffer` 走整块重画)或缩放落到该视界,
仍会卡:实测**整宽单网格渲染 655ms**(补条仅 1.7ms)。逐层定位:
- `BuildPolyline` 只 0.03ms(4481 点、`breaks=1`);
- 直接把同样的 4481 点交给 `Graphics::DrawLines`:**AA round ≈694ms / 无 AA ≈29ms(23 倍)**;
- 把同样的几何做成"像素列包络带"再 `FillPath`:同样慢。
即:该视界下编码器方波形成**每像素列一次整幅跳变的锯齿**,采样 1~2 个/像素,
GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸(关抗锯齿无效是因为原实现只在
`visibleCount > budget(=2/px)` 或"梳状"时关 AA,这一区间两个条件都不满足)。
修法(**几何完全不变,只改"何时走包络 / 何时关 AA"**):
1. **包络带阈值从"每像素 2 个采样(`budget`)"降到"每像素 1 个采样"**:`useEnvelopeBand =
visibleCount > m_plotRect.Width`。1~2 采样/像素区间逐列 min/max 与折线几乎等价(且保留 AA)。
2. **扩展"梳状(关 AA)"判据**(`EmitEnvelopeBand` 新增形参 `laneHeight`):除了原有的"相邻列包络不相交",
再加入「多数列在列内就跨过 >0.5×网格高」或「相邻列上下包络整幅跳变(> 0.25×网格高)」
→ 判为梳状,**本段关闭抗锯齿**(几何逐列 min/max 一字未改)。
3. 实测:整宽单网格渲染 **655ms → 47.7ms**,横向补条 **6.9 → 2.4ms**;
中键拖拽 200 帧:**avg 4.5ms / max 5.8ms / 0 帧 >8ms**(修复前 13~24ms 且含 700ms 尖峰)。
4. 目视核对(`.preflight/perf/dragperf` mode 9 导出 PNG):编码器波形为正确的密集方波包络带。
#### 四、C:并行网格图层渲染(**用户明确解除"不允许多线程"约束**)
- 文档中"图层渲染一律在 UI 线程内同步完成 / 绝不要搬到后台线程"的约束**已按用户要求解除**
(见 §11.2 第 5、14 条的新表述),但**根因(共享可变渲染状态)依然成立** —— 因此并行的
唯一安全姿势是**每条线程持有隔离的渲染上下文**。
- 实现(`PlotView::RebuildLaneLayersParallel`):为每条工作线程准备一个**隔离的 `PlotView`**
(`m_tab` 指向它**自有的** `m_workerTab` 副本,`m_area`/`m_plotRect`/`m_lanes` 都是它自己的),
各自把不同 `laneIndex` 的图层渲染到**不同的 `m_layerFront` 元素**;主线程 join 后按**真实网格下标**
重算 `LaneLayerKey`(工作线程内部按 `laneIndex=0` 渲染)。
- ⚠️ **踩坑**:`BuildLaneVisuals()` 在 `m_tab == nullptr` 时**会清空 `m_lanes`** → 不能靠"把 `m_tab` 置空
来隔离",必须给它一个**非空的、自有的** `TabConfig`(`m_workerTab`)。
- 正确性(`.preflight/perf/dragperf` mode 5):并行 vs 顺序逐网格比较 **0 差异**(5 网格全 0)。
- **收益(诚实结论)**:`RebuildWideBuffer` 并行 31.1ms vs 顺序 32.0ms → **仅 1.03x**。
同一进程自检证明 **4 线程 CPU 真并行(3.98x,hw=20)**,因此**瓶颈是 GDI+ 光栅化本身的串行化**,
不是调度。→ **并行对"光栅化"没有收益**;因此**保持"视界停后每帧补 1 个网格"**的策略
(把一帧从 ~10ms 抬到 ~31ms 反而更容易掉帧),并行只用于"本来就要一次画完"的 `RebuildWideBuffer()`。
#### 五、验证
- `.preflight/perf/dragperf`:mode 0(拖拽帧时)、mode 1(B 拆解)、mode 3(补条逐像素)、
mode 5(并行逐像素)、mode 6(重建耗时)、mode 8(线程真并行自检)、mode 9(PNG 目视)。
- `.preflight/perf/pancheck` 7 场景全 `dy=0`;`.preflight/perf/zoomcheck` mode 0/13/14 无回归
(f1 8~9ms,缩放收敛 3649 差异=预存在残差)。
- `make clean && make all` 零警告。
---
### 9.19 远端「调整」功能带来的性能回归 + 首帧卡顿根治 + 图例交互/托底重做(本轮)
> 接手背景:远端(`c334390`…`940b8e6`)新增了样式区「调整」(系数/绝对值/镜像/偏移)与
> 标题栏内存占用等。本轮先**逐项量化**再修,全部离屏验证(不占用鼠标/键盘/屏幕)。
#### 一、性能回归:`AutoRangeLaneToView` 把 `Style(ref)` 放进了逐采样循环(**8 倍**)
- **症状**:加载 `VERY_BIG_DATA.CSV` 并显示全部信号后,**点一下工具栏「Y 轴自适应」开关**就卡顿
很久;从局部放大状态 `FitAll` 回全局同样卡。
- **量化**(新增 `.preflight/perf/zoomcheck` mode 21/23):
- `SetAllLanesAutoFitToSelection(true,true)`(全视图、5 个网格):**修复前 1200ms → 修复后 0.02ms**;
旧提交(`20b9084`)为 **147ms** —— 即远端把这一步劣化了约 8 倍。
- `AutoRangeLaneToView(0)`(单网格、全视图):**修复前 ≈240ms → 修复后 0.012ms**,且结果与
「逐采样扫描」的参考值**逐位一致**(`match=1`)。
- **根因**:远端为支持「调整」变换,把 `m_dataSet->Style(ref)` **写在了逐采样循环体里**(还写了两次)——
`Style()` 每次都要解析文件/信号下标;2580 万 × 5 次 × 2 = 亿级解析调用。
- **修法(显示效果零变化)**:
1. **把样式指针提到循环外**(`resolvedStyle`),并预计算 `mirrored` / `transform` 两个布尔。
2. **整段自适应走 O(1)**:`timeMonotonic && begin==0 && end==count && !mirrored && !transform &&
finiteCount>0` 时直接取信号**已缓存的 `minValue/maxValue`**(与 `ComputeStats` 同源,
因此结果与逐点扫描完全一致)。
3. 无变换时走**紧凑循环**(无逐点分支,便于向量化);有变换/水平镜像时逐点套用与绘制相同的变换。
- ⚠️ 有「调整」变换时仍必须逐点扫描(158ms/单网格,全视图)——这是变换语义的固有代价。
#### 二、首帧卡顿根治:**空闲预热「每网格信号图层」**
- **复现与量化**(`zoomcheck` mode 22,`VERY_BIG_DATA` 5 网格,同一负载下 A/B):
加载 → 静止 → 第一次「选区放大」的**首帧**:旧提交 **64.3ms**、本轮修复前 **54ms**。
- **根因**:加载后画面走的是「静态场景缓存贴图」,**宽基底图层从未被分配**;用户第一次平移/缩放时
`Paint` 里 `if (!LayersAllocated()) RebuildWideBuffer();` 会在**那一帧同步重建全部 5 个网格图层**
(实测整帧 = 5 个图层同步重建 + 占位拉伸)。逐项拆解(mode 25/26):
占位 `StretchBlt`(HALFTONE)×5 ≈16ms、装饰层命中缓存仅 0.4ms、**其余全是整批图层重建**。
- **修法**:新增 `PlotView::WarmLaneLayersStep()`,在**命中静态场景缓存**的那条快路径里调用
(⇒ 本帧视图与缓存同源、**视界没有变化**、且**不在拖拽中**):
- 图层数组与网格数量不符时先对齐(多余图层 `ReleaseLaneLayer` 后 `resize`);
- 用既有预算机制 `RollWideBuffer()` + `RenderPendingLaneRebuilds(1)` **每帧至多补 1 个网格**,
队列非空时 `PostMessage(kMessagePlotDerivedReady)` 让宿主再送一帧,直到补齐。
- ⇒ 整批重建的代价被**摊到若干静止帧**上,**任何交互帧都不再承担它**。
- **效果**(同一负载,mode 22):首帧 **64.3ms → 13.0ms**(≈5 倍);`zoomcheck` mode 0 逐步缩放
f1 也同步下降。图层数一旦就绪,后续交互全程走"占位拉伸 + 每帧补 1 个网格"。
- ⚠️ 预热只在**视界不变**时推进,因此不会在用户拖动时做无用功;视界一变,宽基底路径自己按预算补。
#### 三、派生信号:把「调整」**烘进数据**,不再继承指纹
- **用户要求原文**:"重建出来的新信号原本不经过调整就已经是预览状态的样子,而不是继承指纹"。
- **旧行为**(`fc77b02`):把源信号的 `gain/absolute/mirrorVideo/mirrorHorizontal/offsetY/offsetTime`
复制给新信号的 `SignalStyle` —— 新信号只是"套了一层同样的调整",数据本身没变,
「调整」面板里还莫名带着一整套参数。
- **新行为**(`Application::BuildDerivedSignal`):
1. 快照源信号样式;
2. 滤波结果算完后,**逐点套用 `ApplyDrawTransform`**(系数 → 绝对值 → 垂直镜像 → Y 偏移)写回采样值;
3. **水平镜像 = 数据反序**:把派生采样数组整体反转(`ApplyDrawTransform` 是逐点函数,
"先变换再反转"与"先反转再变换"等价);
4. 新信号样式一律**保持默认**(`gain=1 / absolute=false / 两个镜像=false / offsetY=0`)。
- ⚠️⚠️ **【已作废,见 §9.21 第三节】原"唯一例外:时间轴偏移 `offsetTime` 仍由样式携带"** ——
用户明确要求"新信号**默认不启用任何「调整」**就必须与预览逐点重合",因此 `offsetTime` 现在也
**按 `time[i] - offsetTime` 重采样烘进采样值**(新信号 `offsetTime` 同样保持 0);
只有"无法烘值"的退化情形(时间轴非单调 / 时间列缺失)才退回把 `offsetTime` 带在样式上。
仅作历史保留:~~它改变的是"绘制在哪个时刻",而派生信号与源信号共用同一个文件时间轴,
无法把时间平移烘进采样数据;若不继承,新信号会相对预览整体横向错位。~~
- 验证(`1b3ab4a` 轮 `verify` harness):`derived.values[i] == ApplyDrawTransform(tf, float(source[count-1-i]))`
逐点一致;新样式各项均为默认。
#### 四、网格 GX 拖拽让位阈值 + 徽标悬停
- **让位判据改为「鼠标指针」**(`LaneDragTargetAt`):`index = floor((mouseY - firstTop)/laneHeight + 0.5)`。
- 旧判据用"**被拖网格的中心**",等价于要求把整个网格拖过去一格才吸附(用户报告"要拖到另一个网格
的位置附近才有吸附提示")。新判据下**只有两个网格时,鼠标越过 G1 的中心水平线,G1 立刻让位并
出现吸附提示**;任意网格数下都成立(进入相邻网格的上半即让位)。
- **GX 徽标悬停提示**:徽标本体画在静态场景 / 装饰缓存里(被冻结),因此悬停高亮必须是**叠加层**
——新增状态 `m_hoverBadge`(`OnMouseMove` 里更新、`OnMouseLeave` 里清零,因为鼠标在同一网格的
徽标范围内移动时 `m_hoverLane` 不变),`PaintLegendHighlight` 在贴图之后**重画徽标**
(不透明底色铺满外接矩形 + `accentSoft` 圆角底 + `accent` 描边 + `textStrong` 文字),
与左侧信号列表里同一个 GX 徽标的悬停效果完全一致;`QueryCursor` 在徽标上返回 `IDC_HAND`。
#### 五、图例:命中矩形在装饰缓存路径下**必须重建**(悬停/点选失效的真因)
- **症状**:一旦用中键平移过视界,网格图例的**悬停高亮 / 点击选中 / 双击改名全部失效**。
- **根因**:`m_legendHits` 只在**整幅重绘**路径开头被 `clear()`,随后靠 `PaintLaneLegend` 重新填充;
而装饰缓存命中时 `PaintLaneLegend` **提前 return**(跳过绘制)→ 命中矩形列表**被清空且永不重建**。
中键平移后必然出现一次"装饰缓存有效的整幅重绘"(结算帧),于是图例从此点不动。
- **修法(结构性)**:把 `PaintLaneLegend` 拆成「**几何 → 命中/装饰矩形 → 绘制**」三段:
- 先用**唯一来源**算出"放得下的条目"及其托底矩形(命中矩形 == 托底矩形,看到的与点得到的严格一致);
- **无论是否命中装饰缓存都重建 `m_legendHits` 与 `m_decorRects`**,之后才 `if (m_useDecorCache) return;`;
- 绘制段只负责画像素。
#### 六、图例托底 + 消除「旧波形像素残留」
- **症状**:中键把密集波形区域推过左上角 GX 编号/图例区域后再移开,**GX/图例周边残留旧波形像素**。
- **根因**:远端 `c334390` 去掉了图例的整块不透明托底后,装饰缓存捕获的那**一条大矩形**
里包含了**它下面的波形像素**(波形会透出),而装饰指纹 `SceneDecorHash()` 不含视界 ⇒
平移贴回时把旧视界的波形一起贴了回来。
- **修法**:
1. **每个图例条目各自一块圆角托底**(`kLegendPlateRadius = 4`),风格与 GX 徽标一致
(`cardBg` 底 + `border` 描边;选中态用 `selection` 底 + `accent` 描边);
2. **装饰矩形改为逐块**(徽标一块 + 每个条目一块 + 每个 Y 轴胶囊一块),
**不再覆盖整条图例带** —— 中间的波形像素因此完全不进缓存;
3. 每块托底先用**不透明底色铺满其"外接整数像素再外扩 1px"的矩形**再画圆角:
否则圆角之外那几个像素仍是波形,会被烘进缓存(`CaptureDecorCache` 的边界由 `floor/ceil` 得出,
必须完全落在不透明填充内)。Y 轴胶囊同理。
4. 悬停高亮(随鼠标变化)画在**贴图之后**:不透明 `accentSoft` 圆角矩形 + `accent` 描边,
并**在原托底上重绘色块与文字**(`LegendHit` 现在记录色块矩形/文本矩形/颜色/已省略文本)——
不透明托底若只画底色会把图例内容整块盖住(只剩一个空色块)。
悬停时**同时**画对应信号的曲线光晕(`PaintSignalHaloFor`)。
- **实测**(`verify` harness):把"宽基底快路径的实时帧"与"同一视界从零重绘的装饰"逐像素比较,
**全部装饰矩形内差异 = 0**(`decor ghost check: total=0 worst=0 rects=23`)——
直接证明缓存像素与时间视界无关、不会残留旧波形。
#### 七、验证工具(本轮新增,均在 `.preflight/` 下,不入库)
- `.preflight/perf/verify_main.cpp`(`build.ps1 -Main verify_main.cpp -Out verify.exe`):
**离屏 Application harness**(`Initialize(GetModuleHandleW(nullptr), SW_HIDE)` + 直接注入鼠标消息 +
从 `m_backDc` 截图),**40 项检查全部通过**,覆盖:自适应正确性(恒等/变换/镜像、与逐点扫描逐位一致)、
派生信号烘值与样式默认、拖拽让位阈值(鼠标越过中线即让位)、平移后图例命中与可点选、
装饰矩形不覆盖整条图例带、图例悬停有可见高亮、GX 徽标悬停有可见高亮且离开后清除、
**装饰矩形内 0 像素残留**。还会导出 `verify_legend.png` / `verify_legend_hover.png` 供目视核对。
- `.preflight/perf/zoomcheck_main.cpp` 新增 mode:20(FitAll from deep zoom)、21(全局 Y 轴自适应开关)、
22(选区放大首帧 + 图层的空闲预热进度)、23(自适应**正确性**与耗时)、24(带着色自适应 FitAll)、
25(首帧拆解)、26(占位拉伸 HALFTONE vs COLORONCOLOR 与装饰重绘耗时)。
- 旧版本 A/B 基线用 `git worktree add --detach ..\jdi_old 20b9084` 建立。
---
### 9.20 装饰层改为「带 alpha 的独立图层」+ 图例圆角托底 + 预览曲线内存释放(本轮)
#### 一、GX 编号 / 图例「圆角外多出一圈直角白边」的根因与修复
- **症状**:把密集波形(如 `VERY_BIG_DATA` 的编码器信号)平移到 GX 编号与图例下方时,
明明是圆角矩形的 GX 标签 / 图例托底,**外边却多出一个直角长方形**(浅色底 = 网格底色),非常突兀。
- **根因**:装饰层原来是「**从后备缓冲区抓取一块不透明矩形**」——
`CaptureDecorCache()` 用 `BitBlt` 把 `m_decorRects`(矩形)里的像素搬进 `m_decorDc`。
要让这块**矩形**像素跨视界可复用(不残留旧波形),又必须在圆角矩形之外**铺一层不透明底色**
(`OpaqueBaseRect`,比元素大 1px 的直角矩形)。两者叠加 ⇒ 圆角之外是"网格底色"而不是波形
⇒ 在密集波形上就是**一圈直角白边**。Y 轴刻度胶囊同理。
- **修法:把装饰层改成带真 alpha 的独立图层**(不再"抓矩形"):
1. `m_decorDc` 改为 **32bpp `CreateDIBSection`**(自上而下),像素可直接读写 alpha;
2. 新增 **`RenderDecorCache()`**:把装饰**单独绘制**进一张全透明的 DIB(`memset` 清零后
`m_renderDecorOnly = true` 逐网格 `PaintLane`)—— 圆角之外保持全透明;
⚠️ 位图覆盖范围 = **客户区原点 → 绘图区右下角**,绘制坐标与正常绘制**完全一致**
(GDI 文本不认 GDI+ 变换,绝不能做坐标平移);
3. `BlitDecorCache()` 改用 **`AlphaBlend`**(`AC_SRC_ALPHA`,GDI+ 写入 DIB 段即**预乘** alpha)
逐块合成 ⇒ 圆角之外透出的是**当前**波形;
4. **`AlphaBlend` 用 `GetProcAddress` 解析**(`gdi32!GdiAlphaBlend`,回退 `msimg32!AlphaBlend`),
**不新增链接依赖**(两台协作设备工具链不同,避免改 Makefile);
5. ⚠️ **alpha 修补**:GDI 文本(`DrawTextW` / `ExtTextOutW`)会把写入像素的 alpha **清成 0**
(GDI 不维护 alpha),若不修补,文字位置会变成"透明洞"(透出波形)。
因此在装饰矩形内把「`A == 0` 且有颜色(`RGB != 0`)」的像素还原为不透明 ——
装饰本就画在不透明底板上,这些像素本来就该不透明;
6. 删除全部 `OpaqueBaseRect`(外扩的直角不透明底色)。Y 轴胶囊与图例托底都只画**圆角形状本身**。
- **顺带修好的一处遗漏**:图例的「`+N`」(空间不足提示)与「(无可见信号)」也是装饰层内容,
必须**登记进 `m_decorRects`**,否则会被画进装饰位图却因不在贴回列表里而永远看不见。
#### 二、图例样式:与 GX 编号徽标同款圆角托底 + 悬停/选中配色统一
- 每个图例条目 = **一个圆角矩形托底**(`kLegendPlateRadius = 4`,与 GX 徽标同风格的
`cardBg` 底 + `border` 描边),内容 = 色块 + 信号名 —— 用户要求"近似于一个 GX 标签,
只是内容换成了图例信息"。
- **选中态与悬停态同配色**(用户明确要求"选中状态与鼠标悬停时的提示效果相同"):
`accentSoft` 底 + `accent` 描边 + `textStrong` 文字 —— 与 GX 徽标的悬停效果完全一致。
- 悬停高亮画在**贴图之后**(叠加层):先铺不透明 `accentSoft` 圆角矩形 + `accent` 描边,
再在其上**原样重绘色块与文字**(否则不透明托底会把内容盖住),并保持**对应信号的曲线光晕**
(`PaintSignalHaloFor`)。`LegendHit` 新增 `radius` 字段供悬停重绘使用。
- 选中态与左侧信号列表**同源**(`DataSet::Selected()`):面板选中 → 图例高亮;图例点选 →
面板行选中。左右一致由同一条数据保证。
- ⚠️ 沿用上一轮的硬约束:`m_legendHits` **必须**在**任何**绘制路径(含装饰缓存命中)先重建。
#### 三、预览曲线内存:thread_local 工作缓冲封顶 + 关闭功能即释放
- **症状**(用户报告):`VERY_BIG_DATA` 加载并显示全部信号 ≈1.42GB;对某个网格开启弱峰调整
(带预览曲线)后立刻 ≈2.17GB;迭代次数拉到 10 后 ≈4.21GB;**点浮动面板的 ✕ 关掉预览,内存仍然
不下降**;只有"关闭整个文件分组"才释放。
- **根因**:`Utilities/Src/DataModel.cpp` 里三处 `thread_local` **工作缓冲**(弱峰调整的
`currentBuffer`/`nextBuffer`/`extremesBuffer`、`ApplyWeakPeakStage` 的 `shiftBuffer`、
迭代均值滤波的 `bufferBuffer`)。整段信号(2580 万采样)计算时,每条后台线程会常驻
**数百 MB**;后台曲线线程池有 **4 条线程** ⇒ 2GB 以上。而 `thread_local` **只在线程退出时**
才析构 ⇒ 关掉预览不释放,只有 `PauseDerivedAsync()`(关文件分组会调用)join 线程后才归还。
- **修法(两处)**:
1. **保留容量封顶**:新增 `kReuseBufferMaxElements = 4M` + `TrimReuseBuffer` +
`ReuseBufferScope<T>`(作用域守卫,**所有退出路径**都会裁剪)。容量超上限的"超大计算"
改为**一次性分配、用完即释放** —— 这类整段计算本身是秒级的一次性操作(结果会被曲线缓存复用),
内存不再随线程数放大;中/小规模仍保留容量以维持"多阶弱峰调整 991ms → 577ms"的收益。
2. **关闭功能即释放**:新增 `AnyDerivedPreviewEnabled()`(本标签页是否还有网格开启
弱峰调整 / 均值滤波 / 边沿计数)与 **`TrimDerivedCurveDataIfIdle()`**;在
`SetLaneWeakPeak/SetLaneMeanFilter/SetLaneEdgeCenterCount(..., false)` 里调用 ——
**关闭最后一个预览功能时立即 `PauseDerivedAsync()`**(join 线程 ⇒ thread_local 析构 ⇒
内存归还)并清空均值/弱峰曲线缓存。
- **实测**(新增 `.preflight/perf/stylecheck` mode 2,`VERY_BIG_DATA`):
`baseline 1202MB → 弱峰(10 迭代, 1 条) 1399MB → 5 条曲线 1792MB → **关闭面板后 1203MB**`
(修复前该值不下降)。5 条整段曲线的驻留仍受曲线缓存预算(`kCurveSpanCacheMaxBytes` 768MB)约束。
- ⚠️ **诚实说明:计算期间的瞬时峰值仍可达 ≈2.2GB**(同一进程重复测量为 1.4~2.2GB)——
一次整段弱峰调整需要 `out` + 2 个 float 工作缓冲(各 ≈103MB / 2580 万采样),
多线程/多次重排时会有 2 份同时在算。这是"整段 O(n) 计算"的固有代价
(要彻底消除需把算法分块或限制大任务的并发度),**但它在计算结束后立刻归还**,
不再像修复前那样永久驻留。
#### 四、性能与保真度实测(`stylecheck` / `zoomcheck`)
| 指标 | 结果 |
| --- | --- |
| `RenderDecorCache`(重建装饰位图,仅装饰内容变化时执行) | **2.40ms**(5 网格) |
| `BlitDecorCache`(**每帧**贴回,`AlphaBlend`) | **0.19ms**(原 `BitBlt` 方案约 0.3ms) |
| `zoomcheck` mode 13(收敛帧 vs 整幅重绘) | **逐像素 0 差异**(此前为 3503 像素) |
#### 五、验证(全部离屏,不占用鼠标/键盘/屏幕)
- `.preflight/perf/verify_main.cpp`(`build.ps1 -Main verify_main.cpp -Out verify.exe`)
**49/49 通过**,本轮新增/强化:
* **装饰位图圆角外 alpha ≈ 0、中心 alpha = 255**(直接读 `m_decorBits`)—— 直接证明"没有直角白边";
* **选中图例 = 悬停配色**(在托底矩形内统计 `accentSoft` 像素:未选中 0、选中 1525);
* **平移(宽基底)帧的装饰与整幅重绘逐像素一致**(只比较装饰**不透明**像素:`worst=1 / 29554 px`;
圆角外的波形属于已知的 ≤0.5px 整数像素平移残差,不参与比较);
* **图例溢出的「+N」提示**:在窄布局 + 合并网格下断言提示矩形已登记且可见(墨迹 570px);
* 图例悬停可见高亮(2011px)、GX 徽标悬停可见高亮(1122px)、装饰矩形内 0 像素残留。
- `.preflight/perf/stylecheck_main.cpp`:mode 0(装饰重建/贴回耗时)、mode 1(密集波形下导出
GX+图例角落 PNG 供目视)、mode 2(预览曲线内存增长/释放)、mode 4(装饰位图 alpha 形状检查)。
- `make release` 零警告。
---
### 9.21 边沿计数改用「绘制值」+ 派生信号烘入时间偏移 + 图例托底统一为 GX 徽标款 + 调整二级面板向上展开(本轮)
> 接手背景:基于 `1b3ab4a`。本轮修的四处都是**交互/统计语义**问题,另有**一处推翻了 §9.19 的结论**
>("派生信号仅 `offsetTime` 仍需继承" —— **已作废**,见下)。
#### 一、两个「边沿计数」必须按**画出来的曲线**判定(原实现按原始采样值 → 恒为 0)
- **定义(用户明确复述,作为权威)**:设定一个横向(Y 轴)光标,信号**绘制出来的曲线**
从**下往上**穿过该幅值 = **上升沿**;从**上往下**穿过 = **下降沿**。
- **症状**:对应用过「调整」的曲线统计恒为 0。例:`VERY_BIG_DATA.CSV` 的「编码器B相」设
系数 50、时间轴偏移 -20s,把横向光标拖到画面中间(方波很规整),**上升沿/下降沿都是 0**。
- **根因**:`EdgeCountsFor` / `EdgeCenterCountsFor` 只比较 **`signal.values` 的原始采样值**,
而横向光标读的是**屏幕上的值**(已乘系数、已加偏移)→ 原始值域与阈值完全不相交。
- **修法**:判定值一律取**绘制值**
`ApplyDrawTransform(style, values[SourceIndexFor(style, count, index)])`;
* 中心轴版同构:两侧都套用同一套变换(`drawn - drawnCenter`),否则比较的不是屏幕上的两条曲线;
* **水平镜像**时曲线数组按**源下标**存储、而区间是**显示下标** → 请求曲线窗口前必须把
`[needFirst, needEnd)` 映射为 `[count-needEnd, count-needFirst)`,否则取到完全无关的一段。
- ⚠️ **缓存必须带样式指纹**:`EdgeCountEntry` / `EdgeCenterCountEntry` 新增 `styleKey`
(`EdgeStyleKey()`:`gain`/`offsetY` 按 1e6 定点 + `absolute`/`mirrorVertical`/`mirrorHorizontal` 位),
否则改完「调整」后仍会命中变换前的旧计数。
#### 二、时间区间**之外**的采样不得计入(区间边界点)
- **症状(用户报告)**:A 在阈值以下、B 超过阈值、C 比 B 高;双光标下**一个光标正好在 B 点**、
另一个在 C 之后 → 计数区间应是"B 之后",**B 本身不该算作一个上升沿**,但旧实现把它算进去了。
- **根因**:`EdgeIndexRange`(时间轴单调时的下标区间)在**左端多取了一个采样点**
(`if (begin > 0) --begin;`,注释写着"使跨越区间边界的边沿也能被计入")→ 采样对 `(A, B)`
的两个端点里有一个在选区之外,却被计入。
- **修法**:**删掉该外扩**,并把语义收紧为「**只有两个端点都落在 `[begin, end)` 内的采样对**
才计入」(`begin = lower_bound(rangeMin)`、`end = upper_bound(rangeMax)`,调用方从 `begin + 1`
枚举到 `end - 1`;增量路径的差集公式不变)。非单调时间轴的逐点 NaN 过滤本来就是这个语义。
- 结果:把区间起点从 `times[crossing-1]` 移到 `times[crossing]` 恰好**少一个上升沿**(已断言)。
#### 三、派生信号把 `offsetTime` **也烘进采样值**(**推翻 §9.19 的"仍需继承"**)
- **用户要求原文**:"派生信号与显示的预览信号需要相同,且烘焙出来的新信号**默认不启用任何「调整」**
时就应该与预览时相同"。→ §9.19 写的"时间轴偏移是唯一的例外,仍由新信号的样式携带"**已作废**。
- **修法**(`Application::BuildDerivedSignal`,在原有「值变换 + 水平镜像反序」之后追加一步):
* 新信号第 i 个采样 = **预览曲线在 `time[i]` 时刻显示的值**
= 已变换的曲线在 `time[i] - offsetTime` 处的取值(**取最近的采样点**);
* 单调时间轴的 `target = time[i] - offsetTime` 随 i 单调不减 → 用**只前进的游标**,整段 O(n);
* 平移后越界的采样置 **NaN**(与预览"该处没有曲线"一致);
* 新信号样式**全部保持默认**(`gain=1 / absolute=false / 两个镜像=false / offsetY=0 / offsetTime=0`)。
- ⚠️ **退化路径**:时间轴非单调或时间列缺失时无法按下标推进取样 → 退回"把 `offsetTime` 带在
新信号样式上"(两条路径的**显示效果相同**,只是一条把偏移留在样式里)。
- 代价:整段重采样需要一份等长临时缓冲(千万级采样 ≈ 数十 MB),用完即释放;属一次性构建操作。
#### 四、图例托底统一为 **GX 编号徽标同款**(并新增更小的 `uiTiny` 字体)
- **用户要求**:"修改图例的托底为 GX 编号同款,大概可以直接复用 GX 编号的样式,只不过「GX」部分
变成图例色块和文本;注意现在的图例文本高度太大了可能放不下 GX 标签同款的托底,这个需要调整
字符大小,并且左右边距和上下边距与 GX 标签的样式相同"。
- **实现**(`PaintLaneLegend`):
* 托底**高度 = `kLaneBadgeHeight`**、上下边界与 `LaneBadgeRect()` **完全一致**(同一 legendBand 内垂直居中);
* 圆角 = **高度的一半**(胶囊形,与徽标的 `badgeHeight * 0.5f` 一致);配色/描边与徽标相同
(`cardBg`/`border`,选中态 `accentSoft`/`accent`);
* **水平内边距 = 胶囊半径**(小于半径时内容会溢出圆角之外);
* 文本改用**新增字体 `UiFonts::uiTiny`(10 逻辑 px)**:原 `uiSmall`(12px)几乎顶满 17px 高的胶囊;
* `LegendItem` 拆出 `plateWidth`(= 内容宽 + 2×内边距),条目步进 = `plateWidth + kLegendItemGap`;
`kLegendPlateRadius` **已删除**(半径一律由徽标高度推出)。
- ⚠️ `LegendHit.rect` **同时是命中矩形与悬停重绘的托底** —— 看到的必须严格等于点得到的;
`LegendHit` 里保存的 `swatch/text/label/颜色/radius` 也必须与绘制用**同一套**(悬停时在不透明托底上原样重绘)。
#### 五、「系数/偏移」「镜像」二级面板改为**向上展开**
- `PopupMenu::Open(scope, bounds, x, y, items, bool expandUp = false)` 新增末位参数:
`expandUp = true` 时 `(x, y)` 是菜单的**左下角**、菜单**向上展开**(默认仍是原来的"向右下展开、
`(x, y)` = 左上角");`BuildLayout` 增加同参数并记录 `m_buildExpandUp`(`RebuildLayout` 复用它,
否则数值回写导致的重新排布会让面板跳回向下展开)。
- `SignalPanel::OpenGainMenu` / `OpenMirrorMenu`:锚点 = `(button.X, button.Y - kAdjustMenuGap)`
(新增常量 `kAdjustMenuGap = 4` 逻辑像素)→ **面板左下角对准按钮左上角、再略微上去一点**;
超出窗口时仍会向左翻转并做边界收敛。
#### 六、验证(全部离屏,不占用鼠标/键盘/屏幕)
- `.preflight/uitest`(窗口 `SW_HIDE` + 直接注入消息 + 从 `m_backDc` 截图)新增 `[AR]`:
* 边沿计数:`plain` / `gain=50`(阈值在**绘制刻度**上)/ `offsetY`(整条曲线被抬到光标之上 → 0 个边沿)/
`mirrorVertical` / `mirrorHorizontal` **逐个与独立参考实现(按定义逐点重算)逐位一致**;
`offsetY` 用例同时给出 A/B:**按原始采样值算会得到 7/6 个边沿**(正是用户报的缺陷);
* 区间边界:把区间起点从 `crossing-1` 移到 `crossing` **恰好少一个上升沿**,且与参考实现一致;
* 中心轴:`absolute + offsetY` 的样式下与参考实现**逐位一致**(32234 个边沿量级);
* 派生信号:无偏移时**全部 128 万采样**逐点等于 `transform(滤波)`;带 +10 个平均采样周期的偏移时,
抽样点等于 `transform(滤波)` 在 `time[i] - offsetTime`(最近采样)处的取值;**样式全默认**;
* 图例托底:`height == 徽标高度`、`Y == 徽标 Y`、`radius == height/2`、色块/文本都在托底之内;
* 调整面板:菜单左边界 == 按钮左边界、`菜单底 == 按钮顶 - 4*scale`、且**不覆盖按钮**(两个按钮各测一次);
* GX 拖拽:`±0.6H` 分别让位 1 格(目标 0 / 3),`±0.4H` 与**恰好 ±0.5H 都不让位**(严格"超过")。
- ⚠️ **同时把 harness 里两处"旧规则"断言改成新规则**(否则它们必然 FAIL,且会误导后来者):
`[AP]` 原断言"派生信号继承调整变换(与预览一致)"→ 改为「样式默认 + 采样值按 `time[i]-offsetTime` 烘焙」;
`[AN]` 原断言"图例带无托底(网格线可透出)"→ 改为「装饰矩形**逐块**、无一块跨越整条带、
未被托底覆盖的列仍能看到底层内容(非网格底色像素 > 0)」。
- 合计 **722/722 通过**;`make release` 零警告。
- ⚠️ **本机提交时 `Target.exe` 常被用户占用**(链接报 `cannot open output file ... Permission denied`):
编译不受影响,**不要 `make clean`**、也不要杀用户进程;用临时 `-o Build/Release/Target_check.exe`
手工链接一次即可验证链接正确性。
---
### 9.22 新标签页默认折起分组 + GX 徽标悬停不改光标 + 标题栏 RAM 口径 + 边沿计数改为预算推进(本轮)
> 动机:用户报告 4 个交互/数值问题 + 1 个性能问题(本机起源设备)。全部离屏验证。
#### 一、新建标签页的信号分组**默认折起**
- `Application::NewTab(bool collapseGroups)`(新增形参):**启动时的首个标签页传 false**(加载文件后可直接勾选信号),
**用户新建的标签页(Ctrl+T / 标签栏 ⊕ / 标签栏右键「新建标签页」)传 true**。
- `TabConfig::SetGroupsCollapsedByDefault(bool)` + `TabConfig::SetAllCollapsed(bool)`(新增);
`SyncToDataSet` 里 `m_collapsed.resize(fileCount, m_groupsCollapsedByDefault)`(原来固定 `false`)——
⚠️ 只影响**新出现的**分组(之后新加载的文件),已存在的分组保持用户当前选择。
`ToggleCollapsed` 里为越界下标补建状态时同样用该默认值。
#### 二、网格 GX 徽标:悬停**不改变**鼠标形状,只有拖拽才是上下箭头
- `PlotView::QueryCursor` 里 GX 徽标分支由 `IDC_HAND` 改为 **`return nullptr`**(= 保持箭头)。
位置仍在原位(先于光标线/图例判定返回),优先级语义不变;
拖拽中(`LANE_REORDER`)仍是 `IDC_SIZENS`。
- ⚠️ 注意不要"顺手"把 `QueryCursor` 的徽标分支改成"落到后面继续判定":那会让经过徽标的光标线
抢到 `IDC_SIZEWE`(原实现用提前 return 就是为了这个优先级)。
#### 三、标题栏 RAM 口径:总工作集 → **私有工作集**(任务管理器"内存"列)
- **症状**:加载大文件再关闭分组后,标题栏还显示几十/上百 MB,而任务管理器只有几 MB。
- **根因**:原实现取 `PROCESS_MEMORY_COUNTERS::WorkingSetSize`(**总工作集**)——
它把映射进来的 DLL/共享页面、以及已释放但尚未被系统回收的驻留页面都算进去;
任务管理器"内存"列的口径是 **私有工作集**(`PROCESS_MEMORY_COUNTERS_EX2::PrivateWorkingSetSize`)。
实测(本机离屏探针):加载 `Problem_Data.csv` 后 total-WS **117.55 MiB** / priv-WS **109.96 MiB** /
commit 138.28 MiB;关闭分组后 total-WS **15.44** / priv-WS **7.62** / commit **8.27** MiB。
- **修法**:`ProcessWorkingSetBytes()` → **`ProcessPrivateWorkingSetBytes()`**,用
`PROCESS_MEMORY_COUNTERS_EX2`(MinGW 的 psapi.h 没有这个类型 → **在 .cpp 里就地声明**,
布局与文档一致),失败(老系统)依次退回 `PrivateUsage`(私有提交)→ `WorkingSetSize`。
- 验证:harness 新增 `[AS]` —— 标题文本必须包含 `FormatByteSize(privWS)`,且在两者格式不同时
**不得**包含 `FormatByteSize(totalWS)`(实测 priv 158.29 MB / total 195.53 MB)。
#### 四、边沿计数(`EdgeCountsFor`):逐块累积 + 每 tick 预算推进(**修掉"拖横向光标卡死"**)
- **量化(探针 `.preflight/probe`,合成方波)**:改前 **6M 采样 = 26.8 ms / 次阈值变化**
→ 折算 **25.8M 采样 ≈ 115 ms/条**。横向光标拖动时阈值每帧都变 ⇒ 单条信号每帧 115ms,
**5 条信号同一网格 = 575ms/帧**(用户报告"开启即卡顿、之后光标几乎拖不动"完全吻合)。
- **新模型**(与 `RenderPendingLaneRebuilds(1)` 同一套"宁可晚一点、绝不卡住前台"的思路):
* 采样对 `k`(采样 `k-1 → k`)按 `kEdgeBlockSamples = 8192` 分块,每块各存上升/下降沿数;
* `EdgeCountEntry::scannedPair` = 下一个待扫描的采样对,`[1, scannedPair)` 已扫完;
* **查询区间**:内部整块用块和相加,两端边界块就地精确扫描(≤ 2 块,代价固定)⇒
拖动**时间**光标的单帧代价与区间长度无关(区间**不进键**,单调时间轴下无需重扫);
非单调时间轴无法用下标区间表达 ⇒ 区间进键(进键与否由 `file.timeMonotonic` 决定);
* **每 tick 预算** `kEdgeScanPairsPerTick = 1200000`(`GetTickCount64` 分桶,同帧多次调用共享),
预算用完即返回当前部分值;`complete` 出参告诉界面"还没算完";
* **拖动横向光标(`EDGE_MOVE`)期间不推进**(阈值每帧都变,推进纯属白做功)→ 直接返回
"最新的完整条目"的结果(数字稳定)+ `complete = false`;
* 快路径:`单调 && 无镜像 && 无调整变换` 时用**紧凑无分支循环**直接比较 `values[k]` 与阈值
(NaN 与任何数比较都是 false ⇒ 天然满足"NaN 不参与且打断连续"的语义,无需 isfinite 分支)。
- **界面**:`EdgeCountsText(rising, falling, complete)` 统一生成文本 —— 完全未算出时显示
**`统计中…`**,部分结果追加 **` …`**;`EdgePanelWidth` 与绘制都改用它(宽度按实际文本测量,
避免推进过程中数字变长被截断)。
- **实测(改后)**:6M 采样首次调用 **1.7 ms**(部分值),5 帧内扫完且与参考实现**逐位一致**;
**单次调用最坏 1.9 ms**;拖动中连查 10 个阈值 **合计 0.11 ms**(O(1)/帧);
区间查询在扫描完成后**一次调用即精确**。
- ⚠️ 本轮**只改了普通「边沿计数」**;`EdgeCenterCountsFor` 仍沿用原增量实现
(它的曲线窗口本来就被 `CurveSpanFor` 限制,不是本次用户报告的场景)。
- ⚠️ harness 的 `EdgeCountsFor` 调用**必须循环到 `complete`**(模拟逐帧推进),
否则拿到的是部分值。
- ⚠️ **harness 抖动(菜单点击类用例)**:留在打开的弹出菜单/内联编辑态里的第一次点击会
被吞掉("输入框外的第一次点击 = 提交编辑"是产品设计)。本轮给菜单类用例加了
`CloseAllMenus(app)` 前置卫生与**允许补点 1~2 次**的逻辑(`[AL]` 的「系数/偏移」按钮、
`[AR]` 的调整按钮),显著降低抖动;但 `[AP]` 的 reset 图标点击仍**偶发**失败(连跑 5 次约 1 次),
重跑即好 —— 属既有 harness 抖动,不是产品缺陷。
#### 五、⚠️ 内存与性能排查报告(用户要求;第 4 项**只排查未改动**)
##### 5.1 内存管理审计(对应"标题栏数字不准 / 怀疑野指针")
- **结论 1:内存没有泄漏,且能完整归还。** 关闭分组(`RemoveFile`)路径实测
`117.55 → 15.44 MiB`(总工作集)/`109.96 → 7.62`(私有工作集)/`138.28 → 8.27`(私有提交)。
该路径会 `PauseDerivedAsync()`(清 `m_curveEntries`/`m_curveJobs`/`m_curvePins`)
→ `InvalidateDerivedCaches()`(清折线/边沿/均值/弱峰/场景缓存);`DataSet::RemoveFile` 用
"前移 + pop_back + shrink_to_fit" 立即归还采样缓冲。
- **结论 2:用户看到的差异来自"口径",不是"错误"。** 见上文第三节 —— 标题原来显示总工作集。
- **结论 3(⚠️ 真实隐患,本轮未修):全仓没有任何 `try`/`catch`。** 所有重分配
(`std::vector::assign/resize`)失败时抛 `std::bad_alloc`;而重计算都跑在
`std::thread` 上(`PlotView::CurveWorkerLoop`、`Application::RunAsyncLoad`,
以及并行图层 / 并行重建),**异常逸出线程入口 ⇒ `std::terminate()` = 进程直接终止**。
即"内存不足"在本项目当前实现里是**崩溃**而不是"报错并放弃本次操作"。
##### 5.2 弱峰调整 "迭代次数 1→10" 内存暴涨(4.4GB)——原因 / 改进方案 / 低内存行为
- **实测(探针,3M 采样合成方波,单次 `ComputeWeakPeakAdjust`)**:
单次计算的工作缓冲峰值 ≈ **103 MiB / 3M 采样 ≈ 34 B/采样**;
**4 条并发计算峰值 +412 MiB**(即 4 × 103 MiB)。按 25.8M 采样线性外推 ⇒
**单次 ≈ 0.9 GB,4 线程 ≈ 3.5 GB**;再加数据本体 1.03 GB 与曲线缓存预算(≤768 MB)
⇒ **峰值 4~5 GB**,与用户观测的 4.4 GB 吻合。
- **构成**(每 1 个采样):`current`+`next` 两个 `float` 工作缓冲 = 8 B;
调用方的 `out`(float)= 4 B;**极值表 `PeakExtremum`(`size_t index` + `double value` + `int type`
= 24 B/极值)**;位移表 `shift`(double)= 8 B/极值。
编码器方波是**极值密集**信号(每个跳变都是极值)⇒ 极值表可达采样数量级 ⇒ **24 B/采样成为主导项**。
- **为什么"拖到 10"会同时拉起 4 份**:`ScheduleCurveJob` 对同 (信号, 曲线种类) 只保留**一个待算**任务,
但**正在计算的任务无法取消**;工作线程池 = `clamp(核数-1, 1, 4)`,参数一路变化时会连续留下
若干个"算到一半的旧参数任务",每个都持有自己的 `thread_local` 大缓冲。
(`CurveSpanFor` 的 `kHugeSignalPreviewSamples` 只在**拖拽期间**不再排队,松手后仍会算一次最终参数。)
- **改进方案(建议,未实施)**:
1. **按信号规模限制并发**:超大信号(> `kHugeSignalPreviewSamples`)时派生计算**串行化**(1 线程)
⇒ 峰值直接除以 4;
2. **缩小极值表**:`{uint32 index; float value; int8 type}` = 12 B(减半),或**分块流式处理**,
不落地整张极值表;
3. **抢占/取消**:任务在每阶之间检查"本参数是否已被更新的参数取代"(比对 generation/最新键),
废弃任务尽早退出,避免为过时参数白算 10 阶;
4. **低内存自适应**:跑之前用 `GlobalMemoryStatusEx` 看可用物理内存,不足时降档
(减少并发 / 拒绝本次重建 / 明确提示"内存不足");
5. **异常保护**(见 5.1 结论 3):给所有线程入口加 `try/catch(std::bad_alloc)`,
把"崩溃"变成"提示 + 跳过本次计算"。
- **低内存场景(1 GB 剩余)现有机制够不够?**
* **有的**:`ReuseBufferScope`(容量封顶,超大缓冲用完即释放)、
`TrimDerivedCurveDataIfIdle`(关掉预览即 `PauseDerivedAsync` 归还内存)、
`kHugeSignalPreviewSamples`(拖拽期不排队)、曲线缓存总字节预算 `kCurveSpanCacheMaxBytes`。
* **没有的**:**任何"峰值上限"与"优雅降级"**。上面 4 项都只解决"算完/关掉之后归还",
不解决"算的时候需要 4 GB"。
* 因此 1 GB 剩余内存时的行为是:先大量换页(全系统卡顿)→ 提交失败则 `bad_alloc`
→ **无线程级 catch ⇒ 进程终止**(崩溃)。用户这台机器内存足够所以只表现为"内存暴涨"。
---
### 9.23 派生曲线内存治理(弱峰调整等)+ 边沿统计改为统一预算状态机(含中心轴)(本轮)
> 背景:用户报告 ① 弱峰调整把迭代次数 1→10 时内存峰值到 4.4GB;② 边沿计数面板
> "鼠标不动就一直显示统计中,瞄到面板才更新",拖动横向光标时显示错误/不更新;
> ③ 中心轴边沿计数**没有被优化到**,一个网格里多个信号各开一项、一改参数整个程序卡住很久。
> 另:**离屏 harness 绝不允许打扰用户的前台输入**(本轮加了进程/线程优先级保护)。
#### 一、边沿统计:统一「逐块累积 + 每 tick 预算」状态机(普通 + 中心轴共用)
- `EdgeScanState`(原 `EdgeCountEntry`)现在是**唯一**的累积结构,`EdgeScanFor(...)` 是唯一实现,
`EdgeCountsFor` / `EdgeCenterCountsFor` 都只是薄包装(后者多传 `centre = true` + `points/iterations`)。
`EdgeCenterCountEntry`/`m_edgeCenterCountCache` **已删除**。
- ⚠️ **必须主动请求下一帧**:`EdgeScanFor` 未完成时调用 `RequestEdgeScanFrame()`
(`PostMessage(kMessagePlotDerivedReady)`)。缺了这一步,用户不移动鼠标就没有重绘 →
预算永远不推进 → **面板永远显示"统计中"**(用户报告②的根因;过去"鼠标移到面板上就好了"
正是因为悬停变化触发了重绘)。
- **阈值/参数变化就地把状态重置**(不新建条目,避免拖动时状态爆炸);
⚠️ 重置当 tick **不展示部分值**(`justReset`)——部分计数每帧都被重置,显示出来就是
"错误且不更新的数字"(用户报告②的另一半)。稳定后展示**递增的部分值 + `…`**,
扫完展示精确值。界面文本统一走 `EdgeCountsText(rising, falling, complete)`。
- **中心轴判定所需曲线必须来自后台**:`EdgeScanFor` 只在 `AcquireCoveringCurve` 命中
(**键完全相同**,不能像绘制那样用旧参数曲线顶替)时扫块,否则 `ScheduleCurveJob` 后本帧不推进。
⚠️ 原先 `EdgeCenterCountsFor` 调 `CurveSpanFor(...)` **没有传 `allowAsyncCompute`**(默认 false)⇒
首帧/参数变化后**同步**算整条均值滤波曲线 ⇒ 界面停顿数秒(用户报告③的根因)。
- `MeanFilterValueAt`(光标读数)也不再调整条同步的 `MeanFilterCurveFor`,改为窗口化 + 异步。
- ⚠️ `ScheduleCurveJob` 现在**幂等**(`m_curveRunningKeys` 集合 + 队列去重):
统计侧会每帧请求同一份整条曲线,若每次都当作新请求,正在算的那份立刻被判过时 →
**请求→作废→重请求 的死循环**(永远算不完)。这是本轮最容易踩的坑。
- 实测(合成方波):6M 采样首帧 **1.4ms**(部分值)、单次最坏 2.1ms;
横向光标拖动中 10 次查询 **1.07ms**;**同一帧内 5 条信号合计 1.3ms**(预算跨信号共享);
保持不动 → 收敛到与参考实现**逐位一致**。
#### 二、派生曲线内存治理(弱峰调整 / 迭代均值滤波)
| 措施 | 效果 |
| --- | --- |
| `PeakExtremum` 字段重排(`double value` + `uint32 index` + `int8 type`) | 24 → **16 字节**;密集极值信号(编码器方波)省 ≈1/3 极值表内存(**不改变数值结果**,不能把 `value` 改成 float) |
| 整段窗口**不再复制采样**(`ComputeDerivedWindow` 整段时直接引用原向量) | 每条整段曲线省 **8 B/采样**(2580 万采样 ≈ **206 MB**) |
| **大任务并发上限**(`CurveJob::hugeLimit`:内存充足 2、紧张 1) | 峰值从 ≈4×单条降到 ≤2×(实测 4 并发 3M 采样 412MB → 324MB,且外推值不再随线程数放大) |
| **内存严重不足时直接拒绝计算**(`DerivedMemoryAllows`:可用 < 输出本体所需最小空间) | 只告警**一次**(`kMessagePlotLowMemory` → 宿主 `SetStatusMessage(..., true)`),**不再崩溃** |
| **低内存节流**(`throttle`:阶与阶之间 `Sleep(1)`,并发降为 1) | "少量多次"慢慢算完,不把内存带宽/CPU 抢光 |
| **取消标志贯通到算法**(`ComputeWeakPeakAdjust` / `ComputeMovingAverageIterated` 新增 `cancel` 参数,每阶 / 每 1M 采样检查) | **过时任务立即退出并归还缓冲**(拖动滑块时旧参数的计算立刻作废) |
| `PauseDerivedAsync`/`StopCurveWorker` 先 `m_curveCancelAll = true` 再 join | 关闭预览 / 结构变化时在算任务尽快退出 |
| `AvailablePhysicalBytes()` 结果按 200ms 缓存 | 绘制路径上不再每帧多次系统调用 |
| 极值表按 `count/8` 预留容量(`try/catch(std::bad_alloc)` 保护) | 避免 push_back 反复扩容的 1.5x 峰值 |
- 复核数据(探针 `.preflight/probe`,3M 采样单次计算):密集极值信号工作缓冲峰值
**33.23 → 26.73 MiB**;4 并发 **412 → 324 MiB**。
- ⚠️ 仍然存在的一次性峰值:一条整段曲线 ≈(8 + 12 + 4)B/采样 ≈ 620 MB(2580 万采样),
这是"整段 O(n) 计算"的固有代价;现在它**有上限、可取消、可拒绝**。
- ⚠️ `MeanFilterCurveFor` / `WeakPeakCurveFor`(整条同步计算)现在**只有测试用到**,
产品代码一律走 `CurveSpanFor`(窗口化 + 异步)。
#### 三、离屏验证绝不打扰用户(用户第三次强调)
- `.preflight/uitest` 与 `.preflight/probe` 现在都在 `main` 开头设置
`SetPriorityClass(GetCurrentProcess(), BELOW_NORMAL_PRIORITY_CLASS)` +
`SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_BELOW_NORMAL)` ⇒
再长的测试也不会与用户的前台编辑抢 CPU("打字不顺畅"的根因)。
- 仍然保持:窗口 `SW_HIDE`、只注入合成消息(不移动真实鼠标/键盘)、每次注入后立即
`ReleaseCapture`(`SetCapture` 是**系统级**的,不释放会把用户的鼠标输入吸走)。
- ⚠️ 新增/修改 harness 时**必须**保留这三项(隐藏窗口 / 释放捕获 / 低于正常优先级)。
#### 四、验证
- `.preflight/uitest` 新增/强化:预算推进首帧不显示部分值 → 第二帧显示递增部分值 →
保持不动收敛到精确值;**未完成时会 PostMessage 请求下一帧**(直接断言);
拖动阈值期间 `complete == false` 且不显示部分数字且总耗时 < 60ms;
5 条信号同帧合计 < 120ms(实测 1.3ms);中心轴与参考实现逐位一致且**无需鼠标移动**即可完成。
- 合计 **751/751 通过**(连跑 3 次稳定);`make release` 零警告。
---
### 9.24 边沿统计:参数变化只作废**被改动的那个信号**(定向失效)
> 用户报告:`VERY_BIG_DATA.CSV` 全部信号放在同一网格、同时启用「边沿计数」与「中心轴边沿计数」,
> 改**一个**信号的中心轴参数后,**两个浮动面板的所有统计**都从头重新计算。
- **根因**:参数提交路径(`EndEdit`)与勾选切换路径里用的是 `m_edgeScans.clear()`——
它把**两个面板、所有信号**的累积状态全部丢掉。用户看到的"全部重新统计"正是它。
- **修法**:新增 **`InvalidateEdgeScansFor(const SignalData* signal, bool centreOnly)`**,
只按键(文件/信号/样式/参数/区间)擦除**该信号**的状态:
* 改**中心轴**参数(`EDGE_CENTER_POINTS/ITERATIONS`)→ 只作废该信号的**中心轴**状态
(`centreOnly = true`);该信号的**普通**边沿计数不受影响,其它信号完全不受影响;
* 改**均值滤波**参数 → 与统计无关(中心轴用的是它自己的条目列表 + 自己的曲线键)→ 不作废任何统计;
* **勾选/取消勾选**信号 → 该信号的统计(与参数相关)依然有效 → **不清任何缓存**
(过去的三个 `clear()` 已全部删除)。
- ⚠️ **硬约束**:凡"参数变化"类失效一律用**定向**接口;**禁止**再对 `m_edgeScans` 整体 `clear()`
(只有**数据层结构变化**才允许,那条路径经 `InvalidateDerivedCaches` → `PauseDerivedAsync`)。
- 验证:`uitest` 新增 `[AT]`——3 个信号合并在同一网格,中心轴统计全部扫完后,通过**真实编辑路径**
(`BeginEdit(EDGE_CENTER_POINTS, 0, L"7")` + `EndEdit(true)`)改**第 2 个**信号的参数,断言:
* 被改信号的中心轴状态**已作废**;其条目参数确实变成 7;
* 另两个信号的 `scannedPair`/`rising` **逐位不变**(累计块和没有被丢弃);
* 被改信号的**普通**边沿计数状态 `scannedPair`/`rising` **逐位不变**(另一个面板完全未失效);
* 未被改动的信号下一次查询**仍然 `complete`**(瞬时返回,不需要重扫)。
- 合计 **774/774 通过**(连跑 3 次稳定);`make release` 零警告。
---
### 9.25 偶发的「改一个参数、其它信号也重新统计」根因:中心轴统计依赖整条曲线缓存
> 用户报告:与 §9.24 同样的操作**偶发**地让其它几个信号重新统计(不是必现,多测几次才出现)。
#### 一、根因(实测定位,不是猜的)
`EdgeScanFor` 的中心轴分支原先靠 **`AcquireCoveringCurve` + `ScheduleCurveJob`** 拿**整条**均值滤波曲线:
- 千万级信号(`VERY_BIG_DATA` 2580 万采样)一条整段曲线 = **≈103 MB**;
每多试一个参数值就多一条(扫描与绘制用同一个键,但**参数变化会产生新键**);
再加上「绘制侧按可见窗口取的那一份」,
极易把曲线缓存推到预算上限(`kCurveSpanCacheMaxBytes = 768MB`);
- 一旦超限,`PruneCurveEntriesLocked()` **从队首淘汰**(最早发布的往往就是**其它信号**的整段曲线)→
那些信号被判为「曲线未就绪」→ 界面显示「统计中…」并在后台重新排队计算 →
**看起来就是"其它几个信号重新统计"**,而且**偶发**(取决于当时缓存里积了多少条目)。
⇒ 统计结果**正确性**没有错,错的是**耦合**:几条信号的统计通过一块共享缓存互相牵连,
而且这块缓存的大小取决于用户试过多少个参数值。**这是本轮真正要根除的问题。**
#### 二、修法:中心轴统计**就地计算**自己需要的一小段曲线
- `EdgeScanState` 新增 `localCurve/localBegin/localEnd/localValid`:扫描时只算
**「当前扫描块 ± 滤波半径」**的均值滤波取值(`ensureLocalCurve`)。
* 半径 = `points * iterations`(迭代 N 次 X 点均值滤波最多向两侧传播 X·N 个采样),
再夹到 `kDerivedCurveGuardMax`;
* 窗口至少覆盖 **8 个扫描块**(`kEdgeBlockSamples * 8`),把「窗口 × 迭代次数」的重复计算摊薄;
* 窗口规模只有几十 KB~几百 KB,**与曲线缓存、与其它信号彻底解耦**;
* 窗口计算成本按 `采样数 × 迭代次数` **计入扫描预算**(不会一帧算太多);
* **扫完后立刻释放** `localCurve`(区间查询需要时按需重算)→ 多信号网格下的常驻内存很小。
- 删除了原来「曲线未就绪就把整 tick 预算清零」的门控(那会让**同一帧里排在后面的其它统计饿死**)。
- 附带收益:**统计不再等后台算整条曲线**,超大文件下第一个统计值出现得更快。
- ⚠️ **验证**:`points ∈ {5,17,64,300} × iterations ∈ {1,2,4}` 共 12 组,
中心轴结果与「整条信号均值滤波后逐点判定」的参考实现**逐位一致**(证明
`X·N` 半径的保护带足够)。
#### 三、同时加固:状态上限与淘汰策略(同类"偶发重算"的另一个来源)
- `kEdgeScanStateLimit` **16 → 64**:`普通 + 中心轴` × 网格内信号数(全部勾选)都会被用到;
上限太小会**淘汰其它信号的已扫完状态** → 它们从头重扫(同样的症状)。
实测 10 个信号(`Problem_Data`)会出现 20 个状态,**原来的 16 就会踩到**。
- 真超限时**优先淘汰「已扫完」的最旧状态**(重算很快),**绝不淘汰正在推进的**。
#### 四、验证(离屏,harness 与探针均为 `BELOW_NORMAL` 优先级)
- `uitest` 新增 `[AU]`(**间歇性复现探针**):5 个信号合并在同一网格、普通 + 中心轴全部启用,
用真实编辑路径**连续改 25 次**某个信号的中心轴参数,每轮 14 帧逐帧检查**未被改动的**统计:
* CASE A = 状态被删除;CASE B = 状态还在但其曲线消失(显示 0/"统计中");
* CASE C = 状态被重置/截断。
修复前:**baseline 0/10 完成**(全部卡在"统计中",因为 `curve entries 0` 时被
「未就绪就清零预算」饿死);修复后:**baseline 10/10 完成,25 次编辑 CASE A/B/C 全 0**,
且 `curve entries 0`(统计已完全不需要后台曲线)。
- 新增「本地窗口 vs 整条曲线」12 组参数的正确性断言(见上)。
- 合计 **789/789 通过**(连跑 3 次:2 次全绿,1 次命中既有的菜单点击类 harness 抖动 ——
`[AP]` reset 图标、面板菜单,重跑即好,与本轮改动无关);`make release` 零警告。
---
### 9.26 「调整」数值符号规则 + 边沿统计按帧推进(含首帧公平分摊)+ 浮动面板排版与弱峰复位控件(本轮)
> 背景(用户报告):① 时间轴偏移 / 系数 / Y 偏移的符号显示不符合要求;
> ② **按住**横向光标不动时边沿计数停在“统计中…”,松开鼠标才更新;
> ③ 改中心轴参数回车后**不移动鼠标**时统计停在部分值(数字后还有 `…`);
> ④ 浮动面板没有信号名长度上限,超长名字把面板顶得很长;
> ⑤ 多信号统计信息错位(没有排版),且只有“下降沿”的数字带 `…`;
> ⑥ 弱峰调整面板需要每行复位控件、上限输入框提示文案、并去掉滑块前多余的 `%` / `N` 标签。
#### 一、「调整」类数值的符号规则(`SignalPanel`)
- `FormatSignedNumber`:**0 不再显示 `+`**(旧实现恒带符号 → `+0.00`);
Y 轴偏移默认值现在显示 `0.00`。
- `FormatGainText`:非 0 正值带 `+`、负值带 `-`、0 不带符号。
⚠️ 仅用于**非编辑态**显示;进入编辑的初始文本仍用不带符号的纯数值(`FormatNumberText`),
避免用户一进编辑框就看到 `+`。
- 新增 `FormatOffsetTimeText(double)`:`Δt:` 后带正负号 ——
`Δt:+5s 60ms` / `Δt:-5s 60ms` / `Δt:0s`(`FormatDurationCompound` 内部取绝对值,
符号由这里补)。打开面板、`CMD_OFFSET_TIME` 提交、`CMD_OFFSET_RESET` 三处统一走它。
#### 二、边沿统计推进:**按帧**预算 + 每状态**公平配额**
- **根因(用户报告②③)**:预算按 `GetTickCount64()` 分桶,而系统时钟粒度约 15.6ms;
`RequestEdgeScanFrame` 的 `PostMessage` 自持重绘会把帧率拉到远高于它 ⇒
**同一 tick 内连画多帧但完全不推进**。“按住不动”“回车后不动鼠标”因此停住。
修法:`Paint` 开头 `++m_edgeScanFrame; m_edgeScanBudget = kEdgeScanPairsPerFrame;`
(**按帧**重置),删除 `m_edgeScanTick`。
- **每状态配额**(修“很多项停在统计中不更新”):`perState = max(kEdgeScanPairsPerStateMin,
kEdgeScanPairsPerFrame / share)`,`share = max(kEdgeScanFairShare = 6, m_edgeScans.size())`;
每个状态记录 `pumpedFrame` / `pumpedPairs`,新帧开始时清零。
⚠️ **不能直接用 `size()` 分摊**:状态是**同一帧内逐个创建**的,第一个状态那一刻 `size() == 1`
会拿走整额预算,同帧后面新建的状态全部被截断为 0 配额(表现为“首帧只有第一条信号推进”)。
- 实测(合成 5 信号 × 200 万采样、全部同一网格):**首帧 5 个状态全部推进**;
**9 帧内全部扫完**(期间不注入任何鼠标消息);统计值与参考实现**逐位一致**;
阈值变化(拖动横向光标)后就地重置,随后“停住不动”仍能扫完且精确。
- 自持驱动断言:未完成时确实投递 `kMessagePlotDerivedReady` —— 这是
“用户不移动鼠标也会持续推进”的**唯一**驱动源,任何“分帧推进”的新逻辑都要照做。
#### 三、浮动面板排版
- **信号名宽度上限** `kEdgePanelNameMaxWidth = 150`(逻辑像素):`EdgePanelWidth` 与绘制
都用 `min(实测宽度, 上限)` 并走 `UiEllipsize`(超长名字不再把面板顶长)。
实测:300 字符名字的面板宽度只比单字符名字宽 **44px**(上限 225px @scale1.5),
不设上限则要多 **5100px**。
- **统计信息两列等宽**(修“错位”):新增 `EdgeRisingText` / `EdgeFallingText` /
`EdgeCountsPending`;面板宽度按 **`2 × max(两列文本宽) + kEdgePanelStatColumnGap`**
预留,绘制时两列各占 `(可用宽 − 间距) / 2` ⇒ 多条信号的“上升沿 / 下降沿”
**列起点固定、严格垂直对齐**(用户明确要求)。
⚠️ 绘制函数 `paintCounts` 与宽度计算必须用**同一套等宽假设**,否则较窄的那一列会被截断。
- **两个数字后面都带 `…`**:旧实现只在整行末尾追加一次,视觉上只跟在“下降沿”的数字后;
现在 rising / falling 各自在未完成时追加 ` …`。
#### 四、弱峰调整面板
- 每行滑块右侧新增**复位控件**(复用「系数/偏移」二级面板的还原图标绘制:
PlotView 内新增 `PaintPanelResetIcon`,与 `PopupMenu::PaintResetIcon` 同一套笔画):
悬停高亮 + `IDC_HAND` + 一次性提示“点击恢复该参数的默认值”;
点击把该行参数恢复默认(幅度滑块 → `amountPercent = 0`;迭代滑块 → `iterations = 1`
**且 `maxIterations` 回到初始上限 10**)。
`EdgePanelLayout` 新增 `rowLevelResets` / `rowIterResets`(并进 `PanelSlotKey` 以保证
悬停高亮能触发重绘),两行按钮共用同一 X(上下对齐),面板宽度计入按钮列。
- 「迭代次数上限」输入框的空内容提示改为 **“输入上限(当前N)”**(旧实现只显示一个数字)。
- 滑块标签去掉多余的 `%` / `N`:`kWeakPeakLevelLabel = L"调整幅度"`、
新增 `kWeakPeakIterationsSliderLabel = L"迭代次数"`;
⚠️ MEAN / CENTER 参数输入框的标签常量 `kWeakPeakIterationsLabel`(`迭代次数 N`)**保持不变**。
#### 五、验证(全部离屏,进程/线程均 `BELOW_NORMAL`,窗口 `SW_HIDE`,不注入真实鼠标/键盘)
- 新增探针 `.preflight/perf/panelcheck_main.cpp`(合成 5 信号 × 200 万采样、同一网格,
只写本地 stdout 的 ASCII):**46/46 通过** —— 覆盖
符号规则 10 条(全部走**真实菜单路径**:`OpenGainMenu` + 读 `PopupMenu` 条目)、
按帧推进 / 不饿死 / 精确性(与独立参考实现逐位比对)/ 自持驱动、
两列等宽预留(含未完成时两处 `…`)、名字上限、弱峰复位控件(存在 / 位置 / 对齐 /
点击恢复默认值与上限)。
- 既有 `verify_main.cpp`(本机版本)**47/47(另有 2 条已知过期断言)**:
`derived offsetTime kept (cannot be baked)`(§9.21 已推翻该规则)与
`mouse in lane1 lower half -> target 2`(§9.21 已把让位判据改为“严格超过 ±0.5H”)。
本机 `.preflight` 是**本机自建**、未随远端同步 ⇒ 这两条是**旧规则断言**,与本轮改动无关。
- `make release` 零警告。
---
### 9.27 数据层大重构:文件→分组 / 时间轴一等公民 / 加载器抽象 + CSV 多时间轴 + MF4 支持(本轮)
> 完整审计与改动清单见 `Documents/refactor_report_2026-10-15.md`。本轮**推翻并替换**了 §12 里
> 「DataFile = 一个文件及其自有时间轴」的旧模型;所有界面行为与显示保持与旧版一致。
#### 一、模型:`DataFile` → `SignalGroup`,时间轴独立成 `TimeAxis`
* **新增 `TimeAxis`**:`{ name, time, minTime, maxTime, monotonic, finiteCount }`。
* **`SignalData`**:删除 `rawHeader`(`name` 的副本)与 `unit`(由列名猜出、全仓无人使用);
新增 **`axisIndex`** 指向所属时间轴 ⇒ 采样值与时间的对应关系**自包含**,不再需要「文件」。
⚠️ 名称字段**只有一个 `name`**(2026-10-16 起去掉 `displayName` 与 `Title()`,见 §9.37 / 第 87 条)。
* **`SignalGroup`(原 `DataFile`)**:`{ name(默认=源文件名), axes[], signals[] }`;
**删除** `filePath` / `fileName` / `time` / `timeColumnName` / `timeMonotonic` / `minTime` / `maxTime`。
新增 `AxisOf(signal)` / `AxisAt(i)` / `TimeRange(low, high)`(多轴并集)。
* **`SignalRef::fileIndex` → `groupIndex`**;`DataSet`:`AddGroup` / `RemoveGroup` / `GroupAt` / `GroupCount` /
`Groups()` / **`RenameSignal` / `RenameGroup`** / `RemapSelectionAfterGroupRemoval` + 新增 **`Axis(ref)`**。
* `TabConfig`:`SetGroupVisible` / `GroupHasVisible` / `GroupAllVisible` / `RemapAfterGroupRemoval`;
`RowKind::GROUP`、`CMD_GROUP_*`。
* **采样/统计 API 首参由 `const DataFile&` 改为 `const TimeAxis&`**:
`SampleSignalAt` / `SampleDisplayedSignalAt` / `FindNearestSample`(Utilities)与
`SampleSpacingPixels` / `VisibleIndexRange` / `EdgeIndexRange` / `MeanFilterValueAt` / `WeakPeakValueAt` /
`EdgeCountsFor` / `EdgeCenterCountsFor` / `EdgeScanFor`(PlotView)。
* 视图层:`ResolvedSignal` 改为 `{ data, style, group, axis, visible }`;
`PolylineCacheEntry::file → group`;`EdgeScanState::file → axis`(统计身份 = 时间轴 + 信号)。
* `TabConfig::ComputeVisibleTimeBounds` 只统计**可见信号真正用到的时间轴**。
#### 二、加载层:格式与模型彻底解耦(新文件)
| 文件 | 职责 |
| --- | --- |
| `Utilities/Inc/DataTable.hpp` | `DataTable`(通用列优先表格)、`LoadProgressInfo`/`LoadProgress`、`TableBlock`(一个时间轴块)、`LoadedSource`(源文件全部块) |
| `Utilities/Inc/DataLoader.hpp` + `.cpp` | **唯一入口** `LoadSignalSource(path,…)`:按扩展名分发(`.csv/.txt` → CSV;`.mf4/.mdf` → MDF4;其它按文本尝试)+ `IsSupportedSignalFile` + `SignalFileDialogFilter` |
| `Utilities/Inc/CsvLoader.hpp` + `.cpp` | CSV 解析 + 多时间轴切分 + 各轴按自己的时间列裁剪 |
| `Utilities/Inc/Mf4Loader.hpp` + `.cpp` | ASAM MDF v4 解析 |
| 删除 | `Utilities/Inc/CsvTable.hpp`、`Utilities/Src/CsvTable.cpp` |
* **唯一装配点** `BuildSignalGroup(LoadedSource&&, paletteBase, group, error, progress)`:与格式无关;
负责**时间轴去重合并**(内容逐位相同只保留一条)、信号命名、调色板取色、统计与粗粒度包络。
⚠️ **新增文件格式时只写一个加载器**,数据模型与全部界面代码**不用改**。
#### 三、CSV 多时间轴(修正用户报告的缺陷)
* 规则:**只有名为 `time` 的列是时间轴**;每个时间列开启新块,其后直到下一个时间列之前的列都属于该块。
* 各块按**自己的时间列**裁剪 ⇒ 支持各轴长度不同(实测 `Multi_Timeline.CSV` → 3 条轴,长度 **7/4/7**);
**不再把其它 `time` 列当作信号显示**。
* 完全没有 `time` 列时保留旧行为(第一列当时间轴,单轴)。
* `LooksLikeTimeHeader` 判据收紧为 `t`/`time`/`timestamp`/`simulation time`/`时间`/`时间轴`
(旧判据「包含 time」会把 `timeout` 误判成时间轴并切出空块)。
#### 四、MF4 支持(实测格式要点,务必保留)
* 加载后**新建一个分组、组名 = 文件名**,文件内全部信号归入该分组(用户要求)。
* **每个通道组(CG)是一条时间轴**;内容相同的时间轴自动合并(实测 27 个 CG → **3 条轴**、**124 条信号**)。
因此 MF4 分组天然是多时间轴的,`SignalData::axisIndex` 让每条信号各归其轴。
* 块布局(MDF 4.10 / CANape 16 实测):
* 块头 24 字节 = `id[4] + reserved[4] + block_len(U64) + link_count(U64)`,随后是 `link_count` 个
**8 字节绝对文件偏移**(0 = 空),数据段紧随其后。DT 可以 0 链接。
* `HD[0] = 第一个 DG`;`DG[1] = 第一个 CG`、`DG[2] = 数据块 DT`;
`CG[1] = 第一个 CN`、`CG[2] = tx_name`、数据段 `+8 = cycle_count`;
**记录长度 = DT 数据段字节数 ÷ cycle_count**(实测 114/110)。
* `CN`:`[2] = 名称 TX`、`[4] = 换算 CC`;数据段 `+0 type`、`+1 sync_type`(**1 = 时间主通道**)、
`+2 data_type`、`+3 bit_offset`、`+4 byte_offset(U32,**字节**)`、`+8 bit_count`、`+12 flags`
(bit0 整列无效 / bit1 有无效位)、`+16 invalid_bit_pos`。
**绝对位偏移 = byte_offset × 8 + bit_offset**。
* `data_type`:0/1 无符号(LE/BE)、2/3 有符号、4/5 IEEE 浮点、≥6 字符串/字节数组(跳过)。
* `CC`:`+0 type`、`+6 val_count`、`+8 phy_range_min`、`+16 phy_range_max`、`+24 cc_val[]`。
**换算公式(实测确认)= `phys = cc_val[0] + cc_val[1] × raw`**(本文件时间主通道是
「double 纳秒 + 线性 1e-9」⇒ 解出 `[0.024320125, 42.3918] s`,与 CC 的物理范围逐位吻合,可自检)。
* **TX 文本块**:规范是「U32 长度 + UTF-16」(4.1 起也可 UTF-8 字节数),
但 **CANape 写的是无长度前缀的 NUL 结尾裸文本** ⇒ 必须先按规范、失败再按裸文本回退,
否则所有通道名都会变成空(本轮踩过)。
* 未实现(遇到则跳过该组):压缩块 `##DZ`/`##HL`/`##SD`、`##DL` 数据列表、字符串/MIME 通道。
#### 五、顺带修复的健壮性缺陷
* `Application::Initialize`:`RegisterClassExW` 返回 `ERROR_CLASS_ALREADY_EXISTS` 时**复用**已注册的类
(旧实现当作致命错误弹**模态**框 ⇒ 同进程第二次构造 `Application` 会**永久阻塞**);
`Shutdown` 现在注销窗口类并销毁窗口。
* UI 文案统一到「分组」:`选中该分组全部信号` / `取消该分组全部信号` / `关闭该分组(从工作区移除)` /
`请关闭部分分组或信号后重试。` / `尚未加载数据,请打开数据文件` / `打开数据文件以查看波形` /
`(可同时加载多个文件,各自成为一个分组)`;对话框过滤器含 `*.mf4;*.mdf`。
#### 六、验证(全部离屏,`BELOW_NORMAL` + `SW_HIDE`,不注入真实鼠标键盘)
* 新增 `.preflight/perf/datacheck_main.cpp`:**69/69 通过** —— 单轴 CSV 回归、多时间轴 CSV(3 轴 / 7-4-7 /
不再出现 `time` 信号)、MF4(组名 / 124 信号 / 3 轴 / ns→s / `setSpeed` 首样本与原始字节人工核对)、
`DataSet` 不变量、派生信号沿用源轴、**MF4 集成**(124 信号并一格渲染 67.5ms/帧)。
* 新增 `.preflight/perf/edgecheck_main.cpp`(ASCII-only,替代上轮被编码破坏的 `panelcheck`):
**46/46 通过** —— 「调整」符号规则(真实菜单路径)、边沿统计按帧推进 / 不饿死 / 逐位一致 / 无鼠标输入也完成、
面板两列等宽与名称限宽、弱峰复位控件。
* 既有 `.preflight/perf/verify_main.cpp`(已适配新 API):**47/47**,另有 2 条**本机未同步的旧规则断言**
(`derived offsetTime kept`、`mouse in lane1 lower half`)——属本机 `.preflight` 版本滞后,与本轮无关。
* `make release` 零警告。
---
### 9.28 信号列表滚动越界绘制 + 边沿统计面板「显示冻结」(本轮,起源设备)
> 用户报告两项:① MF4 文件信号过多、列表需要滚动时,**组名/信号名被画到上方「筛选信号」框上**;
> ② 改完某个信号的中心轴滤波参数并确认后,**全程不移动鼠标**时两个边沿计数浮动面板的统计数字
> 一直不刷新,移动鼠标才显示正确的最终结果。两项都已定位根因并修复;
> 离屏验证 `[A]/[B]/[C]` **39/39 通过**,并做了 A/B(回退修复后两项均如实失败)。
#### 一、列表滚动把文字画到筛选框上(`SignalPanel` + `PaintUtil`)
- **根因**:`UiText` 走 GDI `DrawTextW`,而 **GDI 文本不遵循 GDI+ 的 `Graphics::SetClip`**。
列表虽然用 `g.SetClip(m_listRect)` 裁剪,但被滚出视口的「部分可见」行(尤其是分组行与信号名)
仍会把文字画到列表上方的筛选框区域。`UiFillRect` 对大面积**不透明**矩形走 GDI `FillRect`,同理越界。
- **修法**:`PaintUtil` 新增 **`UiTextClipped(g, text, font, rect, argb, align, clipRect)`**
(在同一个 HDC 上 `SaveDC` + `IntersectClipRect` 并完整还原;`UiText` 重构为转发 `clip = nullptr`,
行为与旧实现逐位一致);`SignalPanel` 新增 `PaintRowText()`(裁剪到 `m_listRect`)与
`FillRowRect()`(夹取到 `m_listRect`),**分组行与信号行的全部文本、行底色/选中底色都改走它们**。
- ⚠️ **新硬约束**:滚动/裁剪类容器内的 GDI 文本必须**显式**裁剪,不能依赖 `g.SetClip`。
#### 二、边沿统计面板在「鼠标不动」时显示冻结(`PlotView::PanelSlotKey`)
- **根因**:浮动面板有**像素图层缓存**(`m_panelDc` + `m_panelSlots` + `PanelSlotKey`)。
界面上显示的「上升沿 / 下降沿」是**分帧推进**出来的中间结果,而 `PanelSlotKey` 里**没有**这些数字
⇒ 「统计推进 → 数字变化」不会让键变化 ⇒ 面板一直贴旧像素 ⇒ 显示停在「统计中…」或某个中间值;
**只有移动鼠标**(改变面板层序 / 悬停 / 鼠标坐标等其它键)才会重绘并显示出早已算完的正确结果。
注意:**扫描推进本身没有停**(`EdgePanelWidth` 每帧都会被调,从而调 `EdgeScanFor` 并自持请求下一帧),
停住的只有**显示**。
- **修法**:`PanelSlotKey` 内按面板实际显示的条目回调 `EdgeCountsFor`(COUNT 面板)/
`EdgeCenterCountsFor`(CENTER 面板),把 `(rising, falling, complete)` 一并 mix 进键
⇒ 推进即让键变化 ⇒ 面板重绘 ⇒ 显示随进度更新;扫完后数字稳定,重新命中缓存(不再每帧重画)。
#### 三、验证(离屏 `.preflight/check`,`SW_HIDE` + 不注入真实输入 + 进程/线程 `BELOW_NORMAL`)
- **[A]** 40 信号同一网格 + 分组展开 ⇒ 列表可滚动;滚轮下滚后断言
「列表上边界**以上**区域逐像素 **0 变化**」(修复前 **1571 像素被污染**),并断言
「列表**内部**像素仍有变化」(证明是“裁剪”而不是“整块不画”)。
- **[B]** 3 信号 × 300 万采样同一网格、同时开启两种边沿计数:断言面板**像素图层缓存键在扫描期间
持续变化** + 面板像素确实刷新(修复前键**不变化** = 显示冻结),并在**不注入任何鼠标消息**的
前提下最终 `complete`(统计值与参考实现一致)。
⚠️ 构造要点:信号名要**足够长**(使面板宽度由固定文字决定)且穿越次数少,否则「数字位数变化」
本身就会改变面板矩形 ⇒ 缓存键也跟着变 ⇒ 该用例在**修复前也会“通过”**(失去判别力)。
⚠️ 校验最终值要**直接读累积状态**(`m_edgeScans`),不要用不同阈值去调 `EdgeCountsFor` —— 它会
把状态**就地重置**,反而丢弃已扫完的结果。
- **[C]** 真实 `TestData/MF4-Record.MF4`(**124 信号 / 3 条时间轴**):同样的滚动断言,0 像素变化
(直接复现用户报告的文件场景)。
- **A/B 验证**:把本轮修复 `git stash` 掉重跑 ⇒ `[A]`(1571 px)与 `[B]`(两个面板缓存键均不推进)
**如实失败**;恢复后 **39/39 通过**(连跑两次稳定);`make release` 零警告。
---
### 9.29 全局 Y 轴自适应跟随网格结构 + 组名悬浮 + 稀疏信号默认点线 + 单点光标吸附 + 放大自动点线(本轮,起源设备)
> 本轮六项(用户 2026-10-15 报告):① 全局「Y 轴自适应」开启时,新勾选信号产生的新网格没有按当前视界自适应;
> ② 全局开启时,多信号网格内取消显示某信号 / 拆分网格后需要重新做一次全局自适应;
> ③ 信号列表滚动时组名要**悬浮**在最上方,直到该组信号全部翻过去;
> ④ 「少数数据点」信号默认用「点线」+ 2px,只有一个点时用最大线宽(10px);
> ⑤ 单点信号的光标吸附(吸附而非卡住);⑥ 放大到采样点稀疏时自动启用点线、退出阈值恢复原样式。
> 离屏验证 **65/65 通过**(连跑 3 次稳定),并做了 A/B(临时禁用六个机制后 **21 项如实失败**)。
#### 一、全局 Y 轴自适应要跟住网格结构
- **根因**:`RebuildLanes`(`DataModel.cpp`)对**新出现的**信号返回默认 `LaneCarry()` ⇒
`fitYToSelection = false`,于是全局开关看着是开的、新网格却不自适应。
- **修法 1(继承)**:重建前先算 `previousAllAutoFit = 非空且每个网格都开着 fitYToSelection`
(这就是「全局 Y 轴自适应」的正式定义),`LaneCarry` 加 `matched` 标记,
`applyCarry` 对新信号用 `previousAllAutoFit` 作为默认值。
- **修法 2(立即算一次)**:新增 `PlotView::ReapplyGlobalAutoFitIfEnabled()`
(判据 = `AllLanesAutoFitToSelection()`,与工具栏按钮同源),在 **`Layout()`**
(网格数量变化走 `Relayout`)、**`RefreshLanes()`**(信号数量变化)、**`SplitLane()`** 之后调用。
⇒ 新勾选信号(按当前视界自适应)、取消显示信号(剩余信号重新收敛)、拆分(每份各自自适应)三项都覆盖。
- ⚠️ **新硬约束**:凡「与网格集合相关」的自动行为,必须同时覆盖 `Layout` 与 `RefreshLanes`
两条路径 —— `Application::SynchronizeView` 是**按“网格数量是否变化”**二选一的
(变化 → `Relayout`→`Layout`;不变 → `RefreshLanes`),只挂一处必然漏掉一半场景。
⚠️ 这两个钩子都**不能**放进 `BuildLaneVisuals()`:它在 `Paint` 里也会被调用(配合 `m_renderMutex`),
在那里自动改样式/自动算 Y 会污染渲染路径。
#### 二、组名悬浮(`SignalPanel`)
- 新增 `StickyGroupRowIndex(rowCount)`(纯计算)+ `PaintStickyGroupRow(g, row)`(钉在列表顶部绘制:
不透明 `panelBg` 底 + 底部一条 `border` 线),`RowRect` 增加 `m_rowYOverrideActive/m_rowYOverrideY`
覆盖行顶部坐标 —— `RowRect` 是行内所有几何(名称框 / ✕ / 徽标)的唯一来源,因此只需覆盖它。
- 判据:分组行 `RowRect(row).Y < m_listRect.Y` **且**本组最后一行的下边界 `> m_listRect.Y`。
整组滚过去后本组组名消失;若下一组有信号可见,则改由下一组悬浮。
- ⚠️ 常规行循环必须**跳过**被钉住的那一行(否则同一行会被画两次:一次在滚动位置、一次在顶部)。
#### 三、稀疏信号的默认样式(`DataModel`)
- 新增常量(`DataModel.hpp`):`kSignalLineWidthMin/Max = 0.5/10`(**SignalPanel 的线宽滑块改为引用它们**,
避免“最大线宽”两处漂移)、`kSparseSignalSampleLimit = 16`、`kSparseSignalLineWidth = 2`、
`kSinglePointSignalLineWidth = kSignalLineWidthMax`。
- 新增 `ApplySparseDefaultStyle(finiteCount, style)`:1 个有效点 → 点线 + 10px;2..16 → 点线 + 2px。
在 `BuildSignalGroup` 与 `InsertDerivedSignal` **两处**调用(只在新建信号时生效,不覆盖用户设置)。
- `ComputeStats` 新增输出 `firstFiniteIndex`;`SignalData` 新增 **`singlePointTime`**
(单点信号的那个时刻,加载时填好)—— 供光标吸附使用,避免在鼠标移动时线性扫描采样。
#### 四、单点信号的光标吸附(`PlotView::SnapTimeToSample`)
- 旧逻辑对「只有一个采样点」的信号无能为力:`SampleSpacingPixels` 要求 ≥2 个采样 ⇒ 永远不吸附
(用户报告“无法准确指向数据点”)。
- 新增半径分支:`signal.singlePointTime` 在视界内且与光标的距离 ≤ `kCursorSnapPointRadiusPixels`
(8 逻辑像素)时吸附过去;半径外**不吸附** ⇒ 自由移动。
⚠️ 这是「吸附」而不是「卡住」:移开半径后光标立刻自由(用户明确要求只是提示性响应)。
#### 五、放大到稀疏时自动启用点线(`SignalStyle` + `PlotView`)
- `SignalStyle` 新增运行态三字段:`autoScatterActive` / `autoScatterSavedMode` / `autoScatterUserOverride`。
- `PlotView::AxisSparseInView(axis)`:采样点间距 > 半个绘图区宽度(与光标吸附上门限**同源**)。
⚠️ **单点时间轴不算稀疏**(它一直最稀疏,默认样式已是点线;若算进去,用户手动改成线段会被立刻改回)。
- `UpdateAutoScatterStyles()`:进入阈值 → 保存原 mode + 切「点线」;退出阈值 → 恢复。
⚠️⚠️ **必须在恢复判断之前先做“用户是否手动改过”的判定**(本机制只写点线,所以自动期间
`mode != LINE_SCATTER` 只能是用户改的):用户改完样式可能**紧接着就缩出去**(例如直接点适应视图),
那次调用的视界已经不稀疏;若等到那时才判定,恢复逻辑会先把用户的修改覆盖掉。
- 钩子:**`SetTimeView`**(视界唯一写入口 ⇒ 缩放/平移/适应视图全覆盖;
平移不改变缩放等级 ⇒ 结果稳定、不会样式抖动)以及 `Layout` / `RefreshLanes` / `SplitLane`。
#### 六、验证(离屏 `.preflight/check`,`SW_HIDE` + 不注入真实输入 + 进程/线程 `BELOW_NORMAL`)
- **65/65 通过**(连跑 3 次稳定):`[A1]` 新网格继承开关且按**当前视界**自适应、`[A2]` 取消勾选后重算、
`[A3]` 拆分后每份各自重算、`[B]` 组名悬浮(含“钉住行与自然位置逐像素一致”与“整组翻过后组名消失”)、
`[C]` 稀疏默认样式(1/2/5/16/17/1000 点边界)、`[D]` 单点吸附(半径内吸附、半径外自由、无残留粘滞)、
`[E]` 自动点线(进入/退出/平移稳定/用户手动改过后不覆盖/再次进入再启用)。
- **A/B**:临时禁用六个机制后重跑 ⇒ **21 项如实失败**(覆盖全部六项),证明断言确有判别力。
- ⚠️ 两个测试手法坑:① 用例之间必须**清空分组**(否则行号/网格号不确定);
② 抓取「列表顶部像素带」时必须**排除右侧滚动条**(滑块位置随滚动变化,实测会混入 54px 假差异)。
---
### 9.30 组名平滑悬浮 + 单点信号初始视图 + 光标吸附统一判定(含自动点线同源)(本轮,起源设备)
> 本轮四项(用户 2026-10-15 第二轮报告):① 组名消失太突兀、且悬浮组名会盖住下一个组名
>(让人误以为后面的信号属于上一组);② 只有一个数据点的信号“全局自适应不生效”、
> 初始视图看不出它的状态;③ 光标吸附体验差且经常错误吸附,改用“一屏数据点 < N×4”的统一判定;
> ④ 放大后自动切点线没按预期工作,改为**与吸附判定完全同源**。
> 离屏验证 **55/55 通过**(连跑 3 次稳定),A/B 禁用三项机制后 **14 项如实失败**。
#### 一、组名悬浮:平滑滚出 + 不遮挡下一组(`SignalPanel`)
- 悬浮位置由 `StickyGroupRowTop(row, rowCount)` 给出:
**`min(列表上边界, 本组最后一行下边界 − 组名高度)`**。
* 本组还有行在列表里 ⇒ 钉在列表上边界;
* 本组最后一行开始滑出上边界 ⇒ 组名跟着一起上移、随滚动条**自然离界**(不再突然消失);
* 因为“本组最后一行的下边界”恰好就是**下一个分组行的自然位置**,所以悬浮组名的下边界
永远不超过下一个分组行 ⇒ **不可能遮住下一组名**(旧实现固定钉在 `m_listRect.Y` 会盖住它)。
- **折叠的分组不悬浮**:折叠时本组的“最后一行”就是它自己那一行,于是悬浮位置 = 它的自然位置
⇒ 随滚动自然划过(旧实现会让它停在最上方遮住下一组)。
- 底部边界线只在“真的浮在其它行之上”(`stickyTop > 自然位置`)时才画,避免与正常绘制有像素差。
- 常规行循环仍必须**跳过**被悬浮的那一行(否则同一行画两次)。
#### 二、单点信号的初始视图(`PlotView::FitSinglePointSignal`)
- 触发条件(用户明确要求):当前**没有任何信号被勾选**、勾选的第一个信号**只有一个有效数据点**
⇒ 旧路径(`FitAll` 的整段自适应)会让时间轴覆盖整条时间列、Y 轴退化成几乎为 0 的区间。
- 新行为:数据点**居中**,**时间跨度 = 1s**、**Y 轴跨度 = 1**(各为“一个大格”);
同时设 `autoRange = false`、`fitYToSelection = false`(否则会被自适应路径改回去)。
- `Application::SynchronizeView` 在首次勾选时先试 `FitSinglePointSignal()`,失败才 `FitAll()`。
- `AutoRangeLaneToView` 的**退化区间**(可见区间里只有一个取值)也改为 `PadRange` 同款留白
`max(1, |v|×0.1)`,不再给“几乎为 0”的区间(那会让幅值网格线挤成一团)。
- ⚠️ **需求作废**:上一轮“有效采样点 2…16 的信号默认改成点线 + 2px”**已取消**
(用户 2026-10-15 明确作废:下面第四项的“放大自动点线”已覆盖那个场景)。
只保留“**只有一个**有效采样点 → 点线 + 最大线宽”这一条。
- `SignalData::singlePointTime` 已升级为 **`sparseTimes` / `sparseValues`**
(有效点 ≤ `kSparseTimesLimit = 1024` 时在加载/插入时缓存全部有效点,按时间升序),
同时服务于:单点初始视图(取时刻与取值)、吸附判定(二分)、最近数据点查找。
#### 三、光标吸附的统一判定(`PlotView`)
- **N = `GridMinorCellCount()`**:网格底衬一横排的小格数,与 `PaintLane`/`PaintTimeAxis`
用**同一套** `NiceStep(span, kTimeTickTarget)` + 五等分算出(所以不随缩放等级大幅变化)。
- **`SignalSnapEnabled(axis, signal, timeShift)`**(用户 2026-10-15 的定义):
**当且仅当**该信号在**当前一屏**内的数据点个数 `< N × 4` 时开启吸附;
判据随视界实时重算 ⇒ 疏密变化的信号“移到稀疏区自动吸附、移回稠密区自动自由移动”。
⚠️ 可见性与点个数一律按**显示时间**(`采样时刻 + timeShift`,`timeShift` = 该信号的
`offsetTime`)判断;且要求视界内**至少有一个可见数据点**(否则“0 < N×4”会把“数据全在
视界之外”的信号误判成稀疏,白切一次点线且没有任何可吸附对象)。
⚠️ 性能:总数不足阈值时直接返回(配合 `sparseTimes` 二分);否则只统计可见下标区间内的
有限值并**攒够阈值即早退**,扫描窗口还有 `threshold × 64` 的上限(防 NaN 长循环)。
- **`SnapTimeToSample`**:判定**只针对鼠标所在网格**(`LaneAtY(m_mouseY)`)——
同一个时间轴上,一个网格达到稀疏标准、另一个没有时,只在前者吸附;
同网格内**所有符合判定的信号**的数据点都参与比较。
⚠️ 一切判定与查找都在**显示时间**(`采样时刻 + offsetTime`)空间进行,详见 §9.31。
**吸附半径 = 一个网格小格的宽度**(与判定同源),且**不超过半个局部采样间距**
⇒ 数据点很稀疏(一屏只有一两个点)时光标在点之间仍可自由移动(吸附 ≠ 卡住)。
⚠️ `LocalSampleSpacing` 对 `finiteCount <= 1` 必须返回 0(“数据点间距无定义”),
否则会拿时间轴的采样间隔充数(NaN 填充的轴上即 1ms)把半径压到极小、单点永远吸不住。
⚠️ 旧实现(`SampleSpacingPixels` 的“下门限 6px + 上门限半屏宽”)**已删除**,不要再恢复。
#### 四、自动点线:与吸附判定**完全同源**(`UpdateAutoScatterStyles`)
- 只有 `SignalSnapEnabled` 为真的信号自动切「点线」;不再满足时(适应视图、或视界从稀疏区
移到稠密区)自动恢复原样式;自动期间用户手动改过则不恢复(用户选择优先)。
- ⚠️ “用户是否手动改过”的判定必须在恢复判断**之前**(用户改完可能紧接着就退出阈值)。
- `AxisSparseInView`(旧的“采样间距 > 半屏宽”)**已删除**。
#### 五、验证(离屏 `.preflight/check`,`SW_HIDE` + 不注入真实输入 + 进程/线程 `BELOW_NORMAL`)
- **55/55 通过**(连跑 3 次稳定):`[A]` 组名悬浮(钉住 / 平滑上移 / 整组滚过后换下一组 /
折叠组不悬浮 / 悬浮下边界不超过下一组名)、`[B]` 单点初始视图(跨度 1s 与 1、居中、
样式为点线 + 最大线宽、两个信号时不再走单点路径)、`[C]` 吸附判定(满视图都关闭 /
稀疏窗口开启 / 无可见点不开启 / 同时间轴不同网格结果不同 / 同网格多信号的点都参与 /
稠密↔稀疏双向动态切换 / 单点靠近吸附远离自由)、`[D]` 自动点线(同源判定 /
恢复 / 用户手动改过不覆盖)。
- **A/B**:临时禁用「悬浮位置公式 / 单点初始视图 / 吸附判定」后重跑 ⇒ **14 项如实失败**。
- ⚠️ 测试手法坑(本轮踩到并修掉):① 每个用例前 `ClearGroups`,否则行号/网格号不确定;
② **测试夹具的时间列必须单调升序** —— 否则时间轴被判为非单调、`VisibleIndexRange` 退化成整段,
于是“稀疏窗口”里会把视界外的稠密点也数进去(本轮的 `mixed` 信号就是这样被误判的);
③ `AddGroup` 会 push 进 `vector<SignalGroup>`,之前缓存的 `SignalData*`/`TimeAxis*`
引**用可能失效**,用完要重新 `Resolve`/`Axis`;
④ 改完 `PlotView.cpp` 后要确认 `make release` 真的重编译了(曾遇时间戳未更新而链接到旧 `.o`,
症状恰好等于“修复没生效”——先 `(Get-Item …).LastWriteTime = Get-Date` 再 make)。
---
### 9.31 自适应范围/时机澄清 + 适应视图与 Y 轴自适应互不干扰 + 吸附跟随时间偏移(本轮,起源设备)
> 本轮四项(用户 2026-10-15 第三轮):① 澄清“自适应范围/时机”的准确说法(并修正记忆里不完善的表述);
> ② 「适应视图」与「Y 轴自适应」互相干扰(相互切换都会重绘、区间会变);
> ③ 「适应视图」在单点信号上让数据点“飞了”;④ 光标吸附没有考虑「调整 → 时间轴偏移」。
> 离屏验证 **39/39 通过**(连跑 3 次稳定),A/B 回退三项修复后 **7 项如实失败**。
#### 一、自适应范围/时机的准确说法(用户澄清)
* **首个网格**显示内容时做完整自适应:**Y 轴与时间轴都**匹配自己的数据范围;
* **只有一个数据点**的信号必须显示在网格**中间**(`FitSinglePointSignal()`:居中 + 1s + 1);
⚠️ **工具栏「适应视图」也要走这条路**(仅一个网格 + 唯一可见信号为单点时),
否则数据点会被挤到网格边缘、看起来“飞了”。
* **后续新增网格**只做 **Y 轴**自适应(`ReapplyGlobalAutoFitIfEnabled`),**不再调整时间轴**。
⇒ 记忆里“额外要求视界内至少有一个可见数据点”这句是**不完善**的表述,完整含义见上
(“可见”指的是单点信号要被居中摆在网格里而不是挪出视界)。
#### 二、「适应视图」与「Y 轴自适应」互不干扰(`PadValueRange`)
- **根因**:两条自适应路径的**留白不同** —— 整段自适应(`ComputeLaneRange` → `PadRange`)用
**6%**,「选区 Y 轴自适应」(`AutoRangeLaneToView`)用 **5%**。视界未变时两者本应完全相等,
于是“点过适应视图 → 打开 Y 轴自适应”会算出不同区间、网格被无意义重绘(实测 **143513 像素**),
反向操作同样(**137326 像素**)。
- **修法**:新增 `DataModel.hpp` 的 **`PadValueRange()`**(含退化区间规则)作为**唯一**留白实现,
`PadRange` 与 `AutoRangeLaneToView` 都调它。
- 验证:`[A]` 在“适应视图 → 开自适应 → 关自适应 → 再适应视图”四个步骤上断言
**生效区间逐位相同**且**绘图区逐像素 0 差异**(含对照组:真缩放会重绘 79581 像素)。
⚠️ 断言必须用 `ComputeLaneRange` 取**生效区间** —— `autoRange == true` 时
`Lane::yMin/yMax` 只是初始占位值(±1),直接读它会得到假失败。
#### 三、吸附跟随「调整 → 时间轴偏移」
- **根因**:采样点 k 画在 `time[k] + offsetTime` 处,而光标存的也是**显示时间**,
但吸附一直在**源时间**空间查找 —— 设过时间轴偏移后,吸附会落到历史位置(用户报告)。
- **修法**:`SignalSnapEnabled` / `HasVisibleSample` / `NearestSnapSample` / `LocalSampleSpacing`
全部增加 `timeShift` 形参:可见性与点数统计比 `time[k] + timeShift`,
`NearestSnapSample` 内部用 `time - timeShift` 回源空间查找、返回时再加回去
⇒ `SnapTimeToSample` 拿到的 `candidate` 是**画面上的**时刻;`UpdateAutoScatterStyles` 同步传 `offsetTime`。
- 验证:`[D]` 偏移 +0.3 时“靠近画面上的点”必须吸到 `源时刻 + 0.3`;偏移=0 时行为不变;
偏移把点移出视界时不开启吸附也不吸(不会吸到看不见的位置)。
---
### 9.32 信号面板滚动条跟随 / 工具栏连点丢状态 / 图层网格线步长 / 多网格单点自适应 / 单点信号拖拽(本轮,起源设备)
> 本轮五项(用户 2026-10-15 第四轮)。离屏验证 `.preflight/check` **67/67 通过**(连跑 3 次稳定);
> **A/B** 回退六处修复后 **17 项如实失败**(含 [C] 的"辅助线 49px vs 前台 25px / 20 vs 40 条 / 最大偏移 539px")。
#### 一、拖动信号面板滚动条、鼠标移到绘图区后"滑块卡在原地不动"
- **根因**:`Application::WM_MOUSEMOVE` 里
`plotOnlyMove = (m_activeTarget == Target::PLOT) || (inPlot && m_mouseOverPlot)`。
拖滚动条时鼠标很容易进入绘图区 ⇒ `inPlot && m_mouseOverPlot` 为真 ⇒ 判定"本帧只影响绘图区"
⇒ 只 `InvalidatePlotRect`;而面板的滚动偏移**已经变了**却没有重绘 ⇒ 滑块看着不动(滚动值其实在变)。
- **修法**:`draggingNonPlot = (m_activeTarget != NONE) && != PLOT && != EDIT_FIELD`,
`plotOnlyMove` 必须先排除它(正在拖拽非绘图区组件时,一律走完整失效)。
- 验证 `[A]`:真实按下滚动条 + 移入绘图区移动两次 ⇒ 滚动偏移 `0 → 2088.7 → 1766.1`、`m_chromeDirty` 为真、松手结束拖拽。
#### 二、工具栏快速连点 / 双击丢状态
- **两个根因**:
(a) 窗口类带 `CS_DBLCLKS`,快速第二次按下被 Windows 合成 `WM_LBUTTONDBLCLK`,而工具栏没处理它
⇒ **那一次按下完全丢失**("连点好几下,开关只切换了几次");
(b) `HandleMouseMove` 只要鼠标不在工具栏上就调 `m_toolbar.OnMouseLeave()` ⇒ 清掉 `m_pressedIndex`
⇒ 随后在按钮上松手什么也不发生(按住期间从按钮边缘划出即命中这条)。
- **修法**:`WM_LBUTTONDBLCLK` 命中工具栏时按"按下"处理(`SetFocus`/`SetCapture`/`OnLButtonDown`);
`m_activeTarget == Target::TOOLBAR` 期间**不调** `OnMouseLeave()`。
- 验证 `[B]`:三次快速点击(第 2/3 次走 `WM_LBUTTONDBLCLK`)⇒ `NONE → SINGLE → DOUBLE → NONE`;
按住 → 移出一屏 → 回到按钮松手仍能切换。
#### 三、网格叠底:拖拽期间竖向网格线错位/丢失 + 操作信号样式时"闪烁"
- **根因**:`PaintLane` 的竖向网格**主刻步长**由 `Tab().ViewMax() - Tab().ViewMin()` 推出,
而渲染信号图层时 `RenderLaneLayerRegion` 把该区间改成了**宽窗口**(≈2× 前台)
⇒ 图层里的辅助线步长 2×、位置与前台不同,且"与主刻重合则跳过"的判据也随之错位
⇒ 贴图后网格线**错位 / 整条丢失**;在"拖拽帧(图层像素)"与"结算帧(整幅重绘)"之间来回切换时就是用户看到的**闪烁**。
- **修法**:新增 `PlotView::m_renderTimeStep`,由 `RenderLaneLayerRegion` 用**前台视界跨度**算好传入
(`PaintLane` 优先用它;与纵向已做对的 `m_wideTickSpan/m_wideTickHeight` 同一思路),渲染完恢复原值。
- 验证 `[C]`:① 直接检查**图层位图自己的像素** —— 图层主刻间距 123px vs 前台 122px、**辅助线间距 25px vs 25px**;
② 子窗口贴图帧与同视界整幅重绘帧的竖线数量一致(**10 主 / 40 辅**)且逐列偏移 ≤1px;
③ 断言贴图帧确实来自宽基底路径(`m_wideLastValid`)。
A/B(回退)如实报:辅助线 **49px vs 25px**、**20 vs 40 条**、最大列偏移 **539px**。
#### 四、多个网格、每个网格只有一个数据点时「适应视图」
> ⚠️⚠️ **本节规则(“左右各留一小格” + `FitSinglePointLanes()`)已被 §9.33 作废/取代**
> (用户 2026-10-15 第五轮:“首先应该对之前的策略做撤回,然后重新设计新的规则”)。
> 保留本节仅为历史脉络:现行规则见 **§9.33**(边界处的单值散点让出**半径**,
> 而不是“留一小格”)。
用户给出的规则(逐条落地在 `PlotView::FitSinglePointLanes()`):
* **时间轴**:左边界 = 所有网格中**最左**的数据点时间,再向左偏**一个网格小格**对应的时间跨度
(`TimeGridCellSeconds()`,与网格绘制同源);右边界 = 最右数据点时间 + 一个小格。
* **Y 轴(逐网格)**:按该网格自己的数据点范围自适应;若网格内存在**时间一致但 Y 值不一致**的多个单点
⇒ 纵向上下边界也各留**一个 Y 小格**的高度(`LaneValueGridCell()`,与网格绘制同源)。
* **最极端情况**(网格中所有数据点都在**同一个时间位置**):每个网格的适应按
"首个网格加载且加载为单数据点信号时"的时间轴对齐方式 —— **跨度 1s、以该时刻居中**。
* 某网格只有一个取值 ⇒ 该网格 Y 跨度 **1** 且居中于该值。
* ⚠️ 规则只在**所有可见信号都是单点**(`finiteCount == 1`)时启用,否则交回常规 `FitAll`。
验证 `[D]`:MERGED 后把 b0 移到新网格(网格0 = {a0=5.0, a1=−3.0}、网格1 = {b0=−2.0})⇒
时间视界 `[0.000, 3.800]`(数据 `[0.1, 3.7]` ± 一小格 0.1)、网格0 区间覆盖 4.5~5.5、
网格1 = `[−2.5, −1.5]`(跨度 1 居中)、同刻退化用例跨度 1 居中于 0.1;并含"有常规信号时规则不生效"的反向断言。
#### 五、单点信号的高亮与拖拽换网格
- `HitTestSignal` 新增**单点分支**(`SinglePointScreenPos()` + 容差 = `max(常规命中容差, 该点绘制半径)`)——
单点信号没有折线可测距,不单独处理就永远悬停不到。
- 悬停:光晕换成**圆环**(复用 `PaintSignalHalo`,单点用环)且**不改鼠标形状**
(`QueryCursor` 不查 `HitTestSignal`);按下后进入常规信号拖拽(`IDC_SIZEALL`、虚影为实心点 + 环)。
- 验证 `[E]`:命中判定、`QueryCursor == nullptr`、光晕 57600 像素、从网格1 拖到网格0 成功。
#### 六、本轮验证方法学(重要教训)
- 离屏 `.preflight/check`:`SW_HIDE` 窗口 + 只注入合成消息 + 进程/线程 `BELOW_NORMAL`;**67/67 通过**。
- ⚠️ **A/B 必须让被测代码真的参与编译**:该 harness 链接的是 `Build/Release/*.o`,
只改源文件不 `make release` ⇒ 测的仍是旧目标文件(实测"回退后依然全绿"的假结论);
`Copy-Item` 还会**保留时间戳**,使 `make` 认为 `.o` 最新(实测"恢复后直接 make 不重编译")
⇒ **回退/恢复后都要把时间戳推到当前,再 `make release`**,最后用临时输出名验证链接
(`make release TARGET_FILE_NAME=Target_check`,因为用户正在运行 `Target.exe` 时它被锁定)。
- ⚠️ **判别力要靠"量"而不是"看起来对"**:本轮的 `[C]` 一开始写的是"两帧逐列比较",
但在图层没被真正使用时两帧**恒等** ⇒ 毫无判别力;直到
(a) 断言帧确实来自宽基底贴图(`m_wideLastValid`)、(b) 直接检查**图层位图自身的网格线间距**,才真正抓住缺陷。
- ⚠️ 网格线检测细节:GDI 写 DIB **不设置 alpha 字节**(比较必须按 24 位掩码);
"竖线"的列阈值**必须 ≥ 半个捕获高度**(横向网格线会让每一列都带上几十像素,
用小阈值会把 1212/1225 列全判成竖线 → 位置比较彻底失真)。
---
### 9.33 「适应视图」边界规则**撤回重做**:单值散点让出半径 + 单点簇视图(本轮,起源设备)
> 本轮(用户 2026-10-15 第五轮)。用户原话要点:
> ① 加载文件后**不勾选任何信号**时点几次「适应视图」,再勾选一个单值信号 —— 初次自适应**没有发生**
> (数据点既不居中也不在网格内);② 只勾选 `WIN_Motor_StallCurrentVolBrk`(单值)+ `m_MOT_Current_DCom[0]`
> (连续)时点「适应视图」,单值信号的点被挤到显示区域外**只剩半个**;
> ③ **撤撤回上一轮的策略**(§9.32 的"左右各留一小格"),重新设计边界规则。
> 离屏验证 `.preflight/check` **62/62 通过**(连跑 3 次稳定);**A/B** 回退四处规则后 **20 项如实失败**
> (含 `the dot is horizontally centred (got 1697, want 1084.75)` = 散点正好压在绘图区**右边缘**、
> `[F] initial view (got 42.386313, want 10)` = 初次自适应压根没发生)。
#### 一、撤回的旧策略(勿再用)
- 删除 `PlotView::FitSinglePointLanes()`("左右边界 = 最左/最右数据点 ± **一个网格小格**")。
- 删除 `PlotView::TimeGridCellSeconds()` / `PlotView::LaneValueGridCell()`(只为它服务)。
- 删除 `PlotView::FitSinglePointSignal()`("首个单值信号:时间跨度 1s + Y 跨度 1")——
初次自适应现在与工具栏「适应视图」**走同一条路径**(`FitAll()`),规则不会再出现两套。
#### 二、新的边界规则(`PlotView::FitAll()`)
1. 先做一次**常规**的全局适应:
* 时间 = `TimeBounds()`(可见信号的**时间轴**范围并集,与 `ComputeVisibleTimeBounds` 同源);
* 幅值 = `ComputeLaneRange()`(含唯一的留白实现 `PadValueRange`,即 **6%**)。
2. **单值散点让位**(上下左右四条边界统一规则):
* 若某条边界被一个**单值信号的数据点**(`finiteCount == 1`,画出来是一个圆点)顶住,
则把该边界再向外让出**该散点的半径**(`SinglePointDotRadius()`,再 +1 像素;
两个方向都迭代两遍,因为"半径换算成多少时间/数值"依赖当前跨度);
* **只在散点会被裁切时**才扩展(散点本来就在内部 ⇒ 视界**一点不动**);
* 边界不是单值散点时 ⇒ 边界就落在这个信号点上,**不留额外余量**
(时间轴本来就不留;幅值仍沿用 `PadValueRange` 的 6%,这是 §11.2 第 62 条确立的
"幅值留白只能有一个来源",本次**没有**改动它)。
* 判断依据是**信号自身的数据点**(不是轴),因此"单值信号与连续信号共用一条时间轴"
(MF4 同一通道组)时不会误判。
3. **特例:全部可见数据点都是单值信号、且时刻完全相同**(`FitSinglePointCluster()`):
* 时间轴以该时刻**居中**、缩放到 **一大格 = 1s**
(视界 = `kTimeTickTarget × 1s` = **10s**,此时 `NiceStep(10, 10)` 正好是 1.0
——用户要求的"一大格 1s 的级别");
* 每个网格的幅值区间以**能完整画出该网格内的全部数据点**为准
(取值不同时 + 两侧半径余量并迭代;退化成单值时以**跨度 1 居中**,
与"单值信号居中显示"的观感一致),并置 `autoRange = false`。
* 该分支不再复位 `ZoomMode()`(用户要求"点适应视图后仍保持已启用的缩放模式")。
#### 三、初次自适应"没有发生"的根因(用户报告 ①)
- 没有任何信号被勾选时 `FitAll()` 也把 `Tab().ViewInitialized()` 置成了 **true**,
于是随后勾选第一个信号时 `Application::SynchronizeView()` 认为"已经初始化过"⇒ **跳过自适应**,
视界停在"无信号"那次的整段范围上(实测 MF4:42.386s 的视界,数据点被推到右边缘外)。
- 修法:`FitAll()` **只有存在可见信号时**才置位 `ViewInitialized()`;
`Application::SynchronizeView` 也不再分两步(不再先试 `FitSinglePointSignal`),直接 `FitAll()`。
#### 四、验证(离屏 `.preflight/check`,`SW_HIDE` + 只注入合成消息 + `BELOW_NORMAL`)
- `[A]` 无信号时连点 3 次"适应视图" ⇒ `ViewInitialized()` 仍为 false;再勾选单值信号 ⇒
初次自适应发生(时间跨度 **10**、以该点居中、幅值跨度 **1** 居中、散点落在绘图区正中)。
⚠️ 其中"幅值跨度 **1**"的期望**已被 §9.35 取代**:单点簇的幅值轴现在与常规路径/「Y 轴自适应」
完全同源(只由 `PadValueRange` 提供留白,退化时跨度 = `2×max(1,|值|×0.1)`)。
- `[B]` 时间边界:只有普通信号 ⇒ 视界**正好**等于数据范围;内部单值点 ⇒ 视界不变;
左/右边界处的单值点 ⇒ 边界外扩,且**外扩量 = 半径 + 1px**(实测 0.013408s),散点完整可见。
- `[C]` 幅值边界:单值点顶住某网格上边界 ⇒ 该网格上边界外扩、散点完整可见;
反之"该网格只有一个取值"⇒ 保持自动自适应(区间 = 退化留白 `max(1,|v|×0.1)` 两侧展开)。
- `[D]` 特例:两个网格、全部为同一时刻的单值点(一个网格内有 3 和 7 两个取值)⇒
时间跨度 **10**、以该时刻居中、`NiceStep(10,10) == 1.0`、每个散点都在自己的网格内。
- `[E]` 回归:普通信号的视界 = 数据范围、各网格保持 `autoRange`;
§9.31 的"适应视图 ↔ Y 轴自适应互不干扰"(区间逐位相同)仍然成立。
- `[F]` **真实文件** `TestData/MF4-Record.MF4`:`signals=124`,`WIN_Motor_StallCurrentVolBrk`
确实只有 **1** 个有效点(t = 0.005492),用它 + `m_MOT_Current_DCom[0]`(**8475** 样点)复现用户场景:
点「适应视图」后散点圆心离绘图区左边界 **16.5px**、半径 15px ⇒ 完整可见
(修复前它正好落在边界上、只剩半个)。
---
### 9.34 浮动面板不穿透 / 网格高亮改由信号列表触发 / 样式区「目标」行 / 联动平滑滚动(本轮,起源设备)
> 本轮四项(用户 2026-10-15 第六轮)。离屏验证 `.preflight/check` **73/73 通过**(连跑 3 次稳定);
> **A/B** 回退六处实现后 **15 项如实失败**。
#### 一、浮动面板上双击不再穿透到网格(用户报告)
- **根因**:实验性功能的浮动面板绘制在网格之上,但 `PlotView::OnDoubleClick` 在
"差值标签 → 光标标签 → 光标线 → 图例" 都不命中时**无条件** `FitAll()` ⇒
在面板上双击任意位置都会让网格莫名其妙地自适应一次。
- **修法**:在 `OnDoubleClick` 的**最前面**(高于一切下层命中)用
`TopPanelAtPoint()` 判定,命中浮动面板则直接消费(面板自身双击无额外语义)。
这是"上层元素的交互不得影响下层"的通用做法,与 `QueryCursor` / `OnLButtonDown`
里已有的面板守卫同源。
#### 二、网格变色高亮改由"信号列表悬停"触发(用户重新定义)
- **旧行为(已取消)**:鼠标在网格里移动 ⇒ `m_hoverLane = LaneAtY(y)` ⇒ 整个网格变色高亮。
- **新行为**:
* 鼠标**在网格里移动不再触发**任何网格高亮;
* 在**信号列表**里悬停某条信号 ⇒ 若它已勾选绘制到某个网格,则该网格变色高亮,
**并且该信号曲线本身也高亮**(复用 `PaintSignalHalo`);鼠标移开即恢复;
* 悬停**未勾选**的信号不产生任何高亮(`LaneOfSignal` 只认可见信号);
* 悬停信号行的 ✕(移除)**不算**"悬停到该信号"。
- **实现要点**:
* `PlotView` 新增 `m_externalHoverSignal` + `SetExternalHoverSignal()`:
⚠️ **必须与 `m_hoverSignal` 分开**——绘图区的 `OnMouseLeave` 会清掉后者,
而列表悬停时鼠标恰恰不在绘图区内;
* 新增 `PlotView::EffectiveHoverLane()`:`m_hoverLane`(浮动面板悬停)优先,
否则取外部悬停信号所在网格;`PaintHoverHighlight` 与光晕都改用它 /
`m_hoverSignal ?: m_externalHoverSignal`;
* `SignalPanel` 新增 `HoveredSignal()`(列表信号行 / 「目标」行),
⚠️ 不能用 `m_hoverRow` 现算(鼠标移出列表即被清、行下标会因重建而失效),
且在 `InvalidateRows()` 里一并作废(结构变化后悬停引用不再可信);
* 同步时机:`Application::Render()` **每帧**同步(覆盖 WM_MOUSELEAVE、数据变化、
切换标签页等全部改变悬停的路径,而不只是 WM_MOUSEMOVE)。
* GX 徽标的悬停高亮仍按鼠标纵坐标现算(它与"悬停网格高亮"已解耦)。
#### 三、样式区顶部「目标」行(用户要求)
- 位置:样式自定义区**最上方**(预设颜色色块之上),与其余五行同高同间距
(`DesiredStyleHeight()` 因此多算一行;标签列文字「目标」)。
- 内容:当前**正在被编辑的目标信号**(= 选中信号)的名称;
**已绘制到网格时**在名称**后方**显示它所在的 GX 标签(`TargetLaneIndex()`)。
- 交互(用户要求"与信号选择区域的信号相同"):
* 名称悬停 ⇒ 一层 `hover` 背景;GX 标签悬停 ⇒ `accentSoft` 底 + `accent` 边;
两者都用手型光标;
* **悬停名称 / GX 标签也触发绘图区的网格高亮 + 曲线高亮**(与列表悬停同效);
* **单击不进入编辑**,只触发"滚动到目标信号"的联动;**双击**才进入改名
(复用 `StyleEditField::SIGNAL_NAME`,用 `m_editRow < 0` 表示"目标是选中信号");
* 名称颜色与列表一致:已绘制 = `text`,未绘制 = `textMuted`。
- ⚠️ 名称 / GX 标签的矩形依赖**文本实际宽度**与**是否已绘制**,
这两者都可能在不重排的情况下变化 ⇒ 用 `UpdateTargetRowGeometry()` 在
绘制 / 悬停 / 命中 / 取光标四处按需重算(带小缓存键)。
#### 四、联动:把信号列表**平滑滚动**到目标信号(用户要求)
- **触发**:绘图区内把某条信号选为当前目标(点击图例、点击信号曲线、双击图例改名)
⇒ `PlotView::SelectSignal()` 记录一个待处理请求(`TakePanelScrollRequest()`),
宿主在鼠标消息处理后取走并调用 `SignalPanel::ScrollToSignalAnimated()`;
另外样式自定义区的**任何交互**(预设色块、自定义颜色入口、样式按钮、
线宽 / 透明度滑块与数值框、「系数/偏移」「镜像」二级菜单、绝对值)与
「目标」行的单击也都会触发同一次滚动。
- **滚动目标位置**(`SignalScrollTarget()`):
* 目标在当前视界**上方** ⇒ 向上滚动到 `rowTop - 组名高度`,
⚠️ **顶部必须避让悬浮组名**(用户明确要求"完全出现时不会被浮动组名遮挡"):
悬浮组名只可能是目标自己那个分组的组名(分组连续排列,见 `StickyGroupRowTop`),
因此让出**一个组行高度**即可;
* 目标在视界**下方** ⇒ 向下滚动到 `rowBottom - 列表高度`;
* 已经完整可见 ⇒ **不动**。
- **动画**(`AdvanceScrollAnimation()`,宿主用 16ms 定时器驱动):
* ease-out cubic(起步快、靠近目标变慢 —— 用户要求"越远时越快,靠近时变慢");
* 时长 = `clamp(距离 × 0.35ms, 130ms, 300ms)`(用户要求"不要太久");
* 用户滚轮 / 拖滚动条 ⇒ **立即取消**动画(否则互相打架)。
- ⚠️ 动画只改"面板的滚动偏移",因此宿主只需持续 `InvalidateRect`(整帧),
由 `Render` 检测 `ScrollAnimationActive()` 自管定时器(与提示浮层淡出同一套做法)。
---
### 9.35 分组折起/展开动画 + 筛选态可折起 + 悬浮组名可交互(含"先滚动再折起")+ 适应视图与 Y 轴自适应彻底同源(本轮,本机)
> ⚠️ **本节的「一」(按行缩放动画)与「三」里的“先滚动到位再折起”已被 §9.36 推翻/取代**
> (用户 2026-10-16 第二轮明确要求“抽拉卷尺”式整体平移,且悬浮组名折起改为**一边滚动一边收起**)。
> 仍成立的部分:二(筛选态折起 + 项数按筛选计数)、三(悬浮组名命中不得穿透)、四(自适应同源)。
> 本轮四项(用户 2026-10-16,本机 = 另一台设备)。离屏验证 `.preflight/perf/panelanim_main.cpp`
> **94/94 通过**(连跑 3 次稳定);**A/B** 三组回退分别如实失败(4 / 13 / 8 项);
> 既有 `verify.exe` **49/49**(顺带修正了 4 条与远端新规则不符的过期断言)、
> `datacheck` **69/69**、`edgecheck` **46/46**;`make release` 零警告。
#### 一、分组折起 / 展开改为动画过渡(`SignalPanel::GroupAnim`)
- **需求**(用户):折起时被折起的信号**向上收起**、下方分组随之上升;
**被折起的组中最下方成员离得越远收起速度越快**,随后**慢慢减速**直到完成;
总时长要在合理范围。
- **实现**:新增 `m_groupAnims`(每分组一条,可并存)+ `GroupRowScale(group, ordinal, rowCount)`:
* 每行速度倍率 = `1 + kGroupAnimDepthBoost(1.2) × ordinal/(行数−1)`,`ordinal` = 该行在分组内
参与列出的序号(0 = 紧邻组名)⇒ **越靠下越快收尽**;
* 每行进度再过 **ease-out cubic** ⇒ 起步快、末段显著减速;
* 总时长 = `clamp(115 + 14×行数, 140, 300)` ms(3 行 ≈157ms;40 行 = 300ms)。
- **接入点**:`BuildRows()` 把每个信号行的高度乘上比例(不再是非 0 即满高);低于
`kGroupAnimMinRowHeight`(0.75 逻辑 px) 的行**不产生**;宿主 `WM_TIMER(kScrollTimerId)`
同时调用 `AdvanceScrollAnimation()` 与 `AdvanceGroupAnimations()`,
`Render` 的定时器判定改为 `ScrollAnimationActive() || GroupAnimationActive()`。
- **行内容裁剪**:新增 `SetRowClip/ClearRowClip`,`PaintRowText`/`FillRowRect` 在动画期间
裁到**本行矩形**(否则 `DT_VCENTER` 的文字会溢出到相邻行)。
- **实测**(`panelanim.exe`):折起 3 行 = 12 帧 / 169ms,内容高度**单调下降**、末态 = 只剩组行;
展开回到**逐像素级精确**的原始行高(无残留);中间帧可见"越靠下的行越矮"。
- **已知取舍**:同一分组在动画中途被再次切换(快速连点折叠三角)会从"干净的一端"重新开始
⇒ 会有一次视觉跳变(未做跨方向插值)。
#### 二、筛选态下分组也能折起 + 项数按筛选结果计数
- **需求**(用户):筛选状态下筛选结果要**能以组为单位折起**,且组名后方的项数显示为
该组内**符合筛选条件**的信号个数;动画与上一条相同。
- **实现**:
* 折起状态分模式保存:普通模式 = `TabConfig::IsCollapsed`(原有),筛选模式 = 新增
`m_filterCollapsed`(默认**展开**)。`IsGroupCollapsed()/SetGroupCollapsed()` 按当前模式分派。
⇒ 筛选里折起不会污染普通列表的布局,清空筛选即恢复。
* `BuildRows()` 不再"筛选时忽略折叠状态";`ListRow::displayCount` 记录当前模式下列出的
信号数,`PaintFileRow` 的「N 项」只读它。
* 筛选文本变化 / 清空筛选 / 数据变化 ⇒ 清空 `m_filterCollapsed` + `m_groupAnims` + 挂起请求。
- **实测**:筛选 `sig_b*` ⇒ 只列匹配组、项数 = 2、无匹配的组不出现;筛选里折起 A 组后
`TabConfig::IsCollapsed(A)` 保持 false;清空筛选后 A 组仍为展开且项数回到 3。
#### 三、悬浮组名可正常交互 + 点击时"先滚动就位再折起"
- **需求①②**(用户):组名悬浮在列表顶部时,它上面的所有元素都应能交互(此前点击会
**穿透到下层行**);且悬浮状态下点击组名折起时,滚动条要**先向上滚动到组名真正完整显示
的位置**,再播放折起动画(筛选状态下同样适用)。
- **根因**:`HitRow` 只按"滚动偏移把指针映射到内容位置"找行,而悬浮组名是**画在列表顶部**的
覆盖层(其自然位置已滚出列表)⇒ 点击它实际命中的是**它下面那一行**。
- **修法**:
* `HitRow` 先用 `StickyGroupRowIndex()` + `StickyGroupRowTop()` 判定是否落在悬浮行矩形内,
命中就临时打开 `m_rowYOverrideActive` 并通过新的 `RowHitPart()` 算行内区域(折叠三角 /
复选框 / ✕ / 名称全部正确);同时跳过**高度为 0** 的行。
* 新增 `ToggleGroupFromRow(group, row)`:若 `m_scrollOffset > 该行内容偏移`(= 正悬浮)⇒
`StartScrollAnimationTo(该行内容偏移, groupIndex)`(把挂起请求记在 `m_pendingGroupToggle`),
滚动到位后由 `AdvanceScrollAnimation` 的结束分支启动折起动画;否则直接切换并播放动画。
分组行的单击与"双击折叠三角"都走这个入口。
- **实测**:悬浮组名的四个可交互区域全部命中该组行(A/B 回退后 13 项如实失败);
点击组名后 `ScrollAnimationActive()==true` 且 `GroupAnimationActive()==false`(**先滚动**),
滚动结束后 `m_scrollOffset == 该行的内容偏移`(组名恰好落在列表顶部)、随后才开始折起动画与
折叠生效。
#### 四、「适应视图」与「Y 轴自适应」彻底同源(单数据点信号)
- **需求**(用户):以 `TestData/MF4-Record.MF4` 为例加载并只显示**一个单数据点信号**时,
点「适应视图」与「Y 轴自适应」得到的**范围不一致**;且若原本开着 Y 轴自适应,
点「适应视图」后 Y 视界会变、并且**「Y 轴自适应」被解除**。
- **根因**(都在 `PlotView::FitSinglePointCluster`,即"全部可见数据点都是单值且同刻"的特例路径):
* 它给每个网格另算了一套幅值区间("散点半径留白 + **跨度 1**"),而 `AutoRangeLaneToView`
走 `PadValueRange`(退化时跨度 = `2×max(1,|v|×0.1)`)⇒ 两者对**同一视界**结果不同;
* 它还显式写了 `lane.fitYToSelection = false` ⇒ 点「适应视图」就把 Y 轴自适应**关掉**了。
- **修法**:时间轴部分仍是"以该时刻居中 + 一大格 1s",但**逐网格幅值轴改为与常规路径、
与 Y 轴自适应完全同源**:`fitYToSelection` 的网格 → `AutoRangeLaneToView()`;否则 →
`autoRange = true`;两者之后都接 `ApplySinglePointValueMargins()`。**不再改写
`fitYToSelection`**。
- **实测**(真实 MF4,`WIN_Motor_StallCurrentVolBrk` 只有 1 个有效点):
适应视图的区间 == `PadValueRange(单点绘制值)`(逐位相同);打开 Y 轴自适应后区间**完全不变**;
再点适应视图后区间不变、`fitYToSelection` 仍为 true、时间视界逐位不变;
「适应视图 ↔ Y 轴自适应」反复往返**逐位稳定**。单点 + 连续信号同网格(常规路径)同样成立。
- **行为变化(有意)**:单点信号的「适应视图」幅值跨度从旧的 **1** 变为
`2×max(1,|值|×0.1)`(即与 `PadValueRange` 一致)。§9.33 用例 [A] 里"幅值跨度 1"的期望
**已作废**;散点仍在网格正中且完整可见(`ApplySinglePointValueMargins` 兜底)。
#### 五、本机探针修正(`.preflight`,不入库)
- 新增 `.preflight/perf/panelanim_main.cpp`(`panelanim.exe`):用真实 `Application`
(`SW_HIDE`)驱动 `SignalPanel` 做上述 [A]–[D],并用真实 `MF4-Record.MF4` 做 [E]。
- ⚠️ **教训**:直接调 `Application::Render()` **不会**重绘面板(`m_chromeDirty` 已在上一次
`Render` 里被清掉)⇒ 探针里必须 `app.m_chromeDirty = true` 再 Render(本轮的"动画模型"辅助函数
`RenderFrame()`),否则行高不会随动画刷新,"看起来动画没生效"。
- 顺带修正本机 `verify_main.cpp` 里 4 条与远端新规则不符的过期断言:
两处幅值留白改为调用 **`PadValueRange`**(唯一来源);派生信号 `offsetTime` 改为断言 **0**
(§9.21 已烘入数据);拖拽换位改为断言**被拖网格自身边界**判据的新目标值。
⇒ `verify.exe` 现为 **49/49 全绿**。
### 9.36 动画改为「抽拉卷尺」式刚性平移 + 筛选态折叠三角修正 + 样式区「目标」行绘制/布局修正(本轮,本机)
> 本轮四项(用户 2026-10-16 第二轮,本机 = 另一台设备)。
> 离屏验证 `.preflight/perf/panelanim_main.cpp` **147/147 通过**(连跑 3 次稳定);
> **A/B** 五组回退分别如实失败(4 / 6 / 6 / 2 / 1 项);
> 既有 `verify` **49/49**、`datacheck` **69/69**、`edgecheck` **46/46**;`make clean && make all` 零警告。
#### 一、分组动画重做为「抽拉卷尺」式**刚性平移**(推翻 §9.35 一)
- **用户原话要点**:折起时所有信号一起向上平移、逐条消失在组名栏里;展开时逐条从组名栏向下冒出;
**卷尺上的元素相对位置不会变**;收起/展开要流畅(性能)。
- **旧实现错在哪**:按「行高×比例」逐行缩放 —— 元素被压扁(相对位置改变),
与“卷尺”完全不同;而且每帧行高都在变 ⇒ 每帧都要重建行集合(本可用性能)。
- **新实现**(`SignalPanel::UpdateAnimGeometry` + `m_rowShift`):
* 动画只产生**一个逐行平移量** `m_rowShift[row]`(内容坐标),**行高永不变**;
平移只作用于**组名行之后**的行 ⇒ 组名是“卷尺外壳”(始终不动),信号行从它下面滑过。
* 收起:`shift = fromShift + (blockHeight − fromShift) × eased(p)`(推到全部藏进组名栏);
展开:推到 0(逐条冒出)。`eased` = ease-out cubic;时长 `clamp(115 + 6×行数, 150, 320)` ms。
* **中途反向切换从当前位移续接**(`GroupAnim::fromShift`)⇒ 连点折叠三角不会跳变
(旧实现会跳回“干净的一端”)。
* `m_contentHeight = 自然高度 − Σ shift`;`RowRect()` 减去本行位移;
`HitRow()` 改为**逐行比较实际绘制位置**(动画中行位置与“内容坐标”不再一一对应)。
* ⚠️ **不得再改成“逐行缩放”**:方案的关键收益是**行高不变 ⇒ 动画期间无需重建行集合**
(探针直接断言中间帧 `m_rowsDirty == false`)。
- **“藏进组名栏”的裁剪**:信号行越过**组名绘制位置的下边界**即被裁掉(`AnimationHideTopForRow`)。
已经完全越过的行整行跳过(省绘制)。
⚠️⚠️ **除了 GDI 裁剪(`PaintRowText`/`FillRowRect` 用的 `m_rowClipRect`)外,必须同时用
`g.SetClip(clip, CombineModeIntersect)` 收紧 GDI+ 裁剪** —— 行内的色块/复选框/网格徽标走 GDI+,
只受 `SetClip` 约束;不收紧就会画到组名栏上面去(A/B 实测失败)。
- **实测**:3 行折叠 ≈ 4–5 帧、平均 **~10.8ms/帧**(离屏 harness 含整帧重绘)、最坏 ~13ms。
#### 二、悬浮组名折起:一遗滚动一遗收起(推翻 §9.35 三 的“先滚动到位再折起”)
- **用户要求**:悬浮组名折起时不再先上滚再播放动画;而是**一遗向上滚动一遗收起**,
保证悬浮组名始终在最上方;动画完成时滚动偏移恰好落在“组名正好在最上方”,且信号已经收起。
- **实现**:`GroupAnim::scrollSynced` —— 滚动偏移与平移量按**同一进度**推进
(`m_scrollOffset = scrollFrom + (scrollTo − scrollFrom) × eased`)。
数学上这让组名的屏幕位置**恒定**(只要起始越界量 Δ0 ≤ blockHeight,即“组名确实悬浮”的常态)。
目标 `scrollTo = min(组名的内容偏移, 折起后可达的最大滚动量)` —— 后者用于“折起后内容比列表还短”
的情况(此时组名在物理上无法停到最上方,取可达值以避免结束时被强行夹取 ⇒ **无跳变**)。
- 旧的 `m_pendingGroupToggle` / `PumpScroll` 两段式机制**已删除**;
用户滚轮 / 拖滚动条 ⇒ `CancelScrollAnimation()` 仅解除同步(折叠/展开继续播放)。
- **实测**:逐帧断言组名的**绘制位置**始终在列表上沿(<1.5px)、滚动单调上移、
结束时 `m_scrollOffset == 组名的内容偏移`(<1px),且随后 `ClampScroll()` 不再移动(无跳变)。
#### 三、筛选态折叠三角状态修正
- **用户报告**:筛选时分组行的小三角维持在了筛选前的状态,展开/折起都不变化。
- **根因**:`PaintGroupRow` 直接读 `m_tab->IsCollapsed(...)`(普通模式的折叠状态),
而筛选模式用的是**独立**的 `m_filterCollapsed`(§9.35 二)⇒ 筛选里折叠时三角形不变。
- **修法**:改为统一的 `IsGroupCollapsed(groupIndex)`(按当前模式分派)。
- **验证**:离屏像素对比 —— 「筛选态折叠」与「普通态折叠」的组名行(含三角)**逐像素 0 差异**;
且筛选态折叠前后三角区域确实有变化(有判别力)。A/B 失败 2 项。
#### 四、样式区「目标」行:绘制与布局修正
- **用户报告**:目标行显示的信号名与 GX 标签都没有正确绘制;双击进入编辑时输入框把 GX 标签包了进去。
- **根因①②**:
* **没画出来**:`PaintTargetRow` 用了 `PaintRowText`,而它把文本裁到 `m_listRect`(列表矩形)——
目标行在**样式区**、根本不在列表矩形内 ⇒ 名称与 GX 文本被**完全裁掉**。
✓ 修法:目标行(以及样式区其它文本)一律用**不裁剪**的 `UiText`;
并在 `PaintRowText` 的注释里写明“**样式区不得使用**”。
* **输入框包住 GX 标签**:`EditFieldRect()` 对 `m_editRow < 0` 返回“内容列全宽”,
而 GX 标签在列内(旧实现是紧跟名称)⇒ 输入框盖住标签。
✓ 修法:返回 `m_targetNameRect`(只管名称那一块),并断言“输入框与名称矩形逐值相等”。
- **布局对齐**(用户要求:“同时出现时左右宽度与其它控件对齐;不显示 GX 标签时名称可以更宽”):
* 名称从**内容列左边缘**开始(与线宽滑轨/数值框同一左边界);
* GX 标签**右对齐到内容列右边缘**(而不是紧跟在名称后面);
* 名称可用宽度 = 到 GX 标签左侧;**无标签时 = 整个内容列**(因此更宽,但仍左对齐)。
- **验证**:断言名称左缘 == `m_widthTrackRect.X`、GX 右缘 == `m_widthValueRect` 右缘、
两者不重叠;并用 `PixelsNear` 断言名称与 GX 的**文本像素确实存在**(旧实现下名称为 0 像素);
无 GX 标签时名称更宽且仍两端对齐。A/B 失败 6 项。
- ⚠️ 注意:`PaintTargetRow` 里的**悬停/点击/双击**三种交互仍要在绘制前重建几何
(`m_targetRowRect` 只依赖布局,而 `m_targetNameRect/m_targetBadgeRect` 依赖名称与网格归属)。
#### 五、本轮探针(`.preflight/perf/panelanim_main.cpp`,不入库)
- 新增/重写的断言组:`[A]` 刚性平移(行高不变、间距恒定、组名行不动、可见行数单调减少、
**逐帧断言组名栏未被覆盖**、无行集合重建、帧耗时)、`[A3]` 中途反向切换续接不跳变、
`[B]` 筛选态折起与计数、`[B2]` 筛选/普通态折叠三角逐像素一致、`[C]` 悬浮组名命中、
`[D]/[D2]` 同步滚动(逐帧钉住 + 恰好落位 + 无跳变)、`[E]` 自适应同源、`[F]` 目标行绘制/布局/输入框。
- ⚠️ **教训(已写入 §11.3)**:探针里直接 `app.Render()` 不会重绘面板(`m_chromeDirty` 已被上次清掉)
⇒ 必须先 `app.m_chromeDirty = true`(`RenderFrame()`)。
⚠️ 动画循环里**最后一帧**会移除行集合并把位移归零,断言必须在
`if (!GroupAnimationActive()) break;` **之后**再继续,否则会把“正常收尾”误判为“位移倒退”。
---
### 9.37 分组折起热区与改名输入框修正 + 数据模型去冗余(单一名称)+ `Components/Shared` 组件 + 二进制瘦身(本轮,本机)
> 本轮(用户 2026-10-16 第三轮,本机 = 另一台设备)。报告:
> `Documents/refactor_report_2026-10-16.md`(已删除上一轮的 `refactor_report_2026-10-15.md`)。
> 离屏验证:`panelanim` **156/156**、`verify` **49/49**、`datacheck` **69/69**、`edgecheck` **46/46**;
> A/B **10 项如实失败**;`make release` / `make debug` 零警告。
>
> ✅ **可复跑证据(后续补上,见 §11.2 第 96 条)**:本节第一/二/三节的三项要求已写成
> `make structcheck` 的第 **[3] 组源码级断言**(折起门限只接受 `DISCLOSURE`/`GROUP_TITLE` 且全文件恰好 1 处、
> `ToggleGroupFromRow` 恰好 2 个入口、改名框右边界 `statsRight - statsWidth - 6*scale` 且不存在
> `right = statsRight;`、`SignalData`/`SignalGroup` 各只 1 个字符串字段且名为 `name`、
> 全仓 `displayName`/`Title()`/`Set*DisplayName` 0 处、过时措辞 0 处、已修正注释确实存在)。
> **判别力**:回退这两处 UI 修复 ⇒ `panelanim` **147/156**(9 项如实失败,文案点名
> “group rename box stops before the item counter”“clicking the group name does not collapse the group”)、
> `structcheck` **90/93**;恢复后 **156/156** 与 **93/93**。数据层另有 `datacheck` 运行时断言:
> `RenameSignal`/`RenameGroup` **只写一个 `name` 字段**、空名被拒(`69/69`)。
#### 一、分组折起热区:只有折叠三角与「组X」标签可折起
- **需求**:点分组**名称**不折起;只有折叠三角或「组X」标签才折起。
- **实现**:`RowPart` 新增 **`GROUP_TITLE`**;`RowHitPart()` 为分组行判
三角 → 复选框 → 关闭 ✕ → **「组X」标签矩形(`checkRect.Right + 8*scale` 起,宽 `GroupLabelWidth`)**
→ 否则 `BODY`;`OnLButtonDown` 仅在 `DISCLOSURE` / `GROUP_TITLE` 时调用 `ToggleGroupFromRow()`。
- 附带:双击「组X」不再进入改名(该区域现为折起热区);双击分组名仍改名。
#### 二、分组改名输入框不再吞掉「N 项」
- **根因**:`RowNameRect()`(分组行)右边界原先到关闭 ✕ 左侧,把右侧项数文本也框了进去。
- **实现**:右边界改为 `项数左边缘 − 6*scale`;新增 **`GroupLabelText(groupIndex)`** 与
**`GroupStatsText(row)`** 作为“绘制 / 命中 / 宽度”的唯一文本来源(`PaintGroupRow` 与
`RowNameRect` 共用),避免名称宽度与实际绘制文本漂移。
#### 三、`SignalData` / `SignalGroup` 只保留**一个**名称字段
- **依据**(用户):信号靠 `SignalRef` 下标定位、从不按名字匹配 ⇒ `name` + `displayName`
是同一语义的两份副本。
- **改动**:删除 `displayName`;`name` 成为唯一名称字段(重命名即写入它);
**删除 `Title()`**(31 处调用点改为 `.name` / `->name`);
`DataSet::SetSignalDisplayName` → **`RenameSignal`**、`SetGroupDisplayName` → **`RenameGroup`**。
- ⚠️ **语义收紧**:`RenameSignal/RenameGroup` 对**空串 / 纯空白一律拒绝**(不再表示“恢复源名称”)
—— 名称不再有回退值。
- 另删除 `Application::m_loadingBytes`(**全仓既不读也不写**的死成员)。
#### 四、新增 `Components/Shared` 组件(通用 UI 控件)
```
Components/Shared/Inc/UiControls.hpp
Components/Shared/Src/UiControls.cpp
```
- 从 `Utilities/PaintUtil.*` **迁出**全部“控件类”绘制/命中:图标(+ / ✕ / 三角)、复选框、
滑块(`UiSliderRatioFromX` / `UiDrawSlider`)、内联输入框(`UiDrawEditField` /
`UiDrawEditFieldContent` / `UiEditTextLeft` / `UiEditOffsetAt` / `UiEditFieldMouseDown` /
`UiEditFieldMouseMove`)、颜色样块(`UiDrawSwatch` / `UiSwatchNeedsOutline`)、
取值胶囊配色(`UiInk` / `UiColorLuminance` / `UiContrastInk`)。
- `PaintUtil.*` 只保留**低层绘制原语**(矩形 / 圆角 / 直线 / 批量实线 / 颜色混合 / 文本与测量)。
- ⚠️ **依赖方向固定**:`Components/* → Components/Shared → Utilities`;
`Utilities` **不得**再包含任何控件代码(也不要 include `UiControls.hpp`)。
- ⚠️ 新增组件目录必须同时登记进 `Makefile` 的 `CXX_INCLUDES_PATHS` / `CXX_SOURCES_PATHS`
以及 `.preflight/perf/build.ps1` 的 `$includes` / `$srcDirs`。
#### 五、二进制瘦身:1 013 248 → **764 416 B(−24.6%)**
- **真因**是 **`Resources/JustDataInspector.ico` 的 256×256@32bpp 条目以未压缩 BMP 存储
(270 376 B,占整包 27%)**,而不是 `PlotView.cpp`。改为 **PNG 存储**(Vista+ 标准做法,
观感零变化)⇒ 该条目 270 376 → **11 280 B**、整个 ico 302 430 → **43 334 B**。
- ⚠️ **拆分大文件不会减小机器码**(“拆分 ⇒ 瘦身”不成立);实测
`-ffunction-sections -fdata-sections -Wl,--gc-sections` 在本工程**反而 +9 216 B**(已回退)。
- ⚠️ `.o` 体积受节对齐/重定位表主导,**判断“代码搬去哪了”要看 `nm`,不要看 `.o` 字节数**。
#### 六、过时注释 / 命名批量修正(承接上一轮未做完的部分)
- 参数与成员注释:`文件下标`→`分组下标`、`文件内信号下标`→`组内信号下标`、`所属文件下标`→`所属分组下标`
等一批;措辞统一 `文件分组`→`分组`、`移除该文件`→`移除该分组`、`打开 CSV 文件`→`打开数据文件` …;
- **文不对题的形参名**:`VisibleIndexRange(const TimeAxis& file, …)` → `axis`;
`Application::LoadFileAsync(…, SignalGroup& file, …)` → `group`;
- **函数名里的 “File 语义”**(上一轮只改了 `RowKind::FILE`):`PaintFileRow`→`PaintGroupRow`、
`FileCloseRect`→`GroupCloseRect`、`OpenFileMenu`→`OpenGroupMenu`、
`FileGroupLabelWidth`→`GroupLabelWidth`、`StyleEditField::FILE_NAME`→`GROUP_TITLE`;
局部变量 `fileRow/fileHeight/isFile` → `groupRow/groupHeight/isGroup`;
- 修复 `DataModel.hpp` 两处被压扁的注释块(`/**\t\t * @brief …`)与 `Application::NewTab`
上方重复的文档块;
- ⚠️⚠️ **新增标识符前必须确认不是 Windows 头里的宏**:本轮 `GROUP_NAME` 撞上
`nb30.h`(由 `windows.h` 引入)的 `#define GROUP_NAME 0x80`,导致枚举编译失败,改名
`GROUP_TITLE` 解决。同类高风险名:`GROUP_NAME`、`TIME`、`SMALL`、`NEAR`、`FAR`、`IGNORE`。
#### 七、多文件拆分(本轮**已完成**,不再是遗留项)
✅ **可复跑证据:`make structcheck`(= `Tools/structcheck.ps1`,入库脚本,62 条断言全绿,退出码 0/1)**
—— 脚本从磁盘与 `nm` 两路核验「文件是否真被拆开 / 职责是否真搬走 / 新 TU 是否真进产物 / 构建是否登记完整」,
不再依赖散文描述。实测行数:`PlotView.cpp` 705(原 13 695)、`PlotViewPaint.cpp` 5 950、
`PlotViewInteraction.cpp` 2 537、`PlotViewFeatures.cpp` 2 821、`PlotViewInternal.hpp` 884;
`SignalPanel` 1 019 / 614 / 1 925 / 288;`Application` 1 326 / 1 071 / 237 / 286;
`DataModel` 1 459 / 666 / 312(25 个实现文件、每个目录都登记在 `Makefile`)。
四个大文件都按同一手法拆成多个协作文件(私有内部头 + 具名命名空间 + 全 `inline` + 按名字分桶搬移):
| 原文件 | 拆分后 |
| --- | --- |
| `PlotView.cpp`(13 695 行 / 544 KB) | `PlotView.cpp`(787) + `PlotViewPaint.cpp`(6 367) + `PlotViewInteraction.cpp`(2 715) + `PlotViewFeatures.cpp`(3 005) + `PlotViewInternal.hpp`(936) |
| `SignalPanel.cpp`(4 184 行) | `SignalPanel.cpp`(1 141) + `SignalPanelPaint.cpp`(697) + `SignalPanelInteraction.cpp`(2 095) + `SignalPanelInternal.hpp` |
| `Application.cpp`(3 135 行) | `Application.cpp`(1 427) + `ApplicationCommands.cpp`(1 184) + `ApplicationLoading.cpp`(270) + `ApplicationInternal.hpp` |
| `DataModel.cpp`(2 615 行) | `DataModel.cpp`(1 583,含自由函数区) + `DataModelTab.cpp`(732) + `DataModelInternal.hpp` |
⚠️⚠️ **关键手法(要做同类拆分时必须照做)**:
1. 原文件的**匿名命名空间**(PlotView 约 870 行)搬进 `<组件>Internal.hpp`,改成**具名**命名空间,
并把其中**每个函数与常量标 `inline`** ⇒ 多 TU 共用同一定义(不重复符号、不多份代码)。
2. 头部末尾 `using namespace <ns>;` ⇒ 各 .cpp 里原有的非限定调用**一行都不用改**。
3. ⚠️ `[[maybe_unused]]` 之类属性**必须排在 `inline` 之前**;`struct`/`enum`/`using` 不可加 `inline`。
4. ⚠️ **首个成员定义之前的所有行**(通常是命名空间外的自由函数,DataModel 有约 390 行)
必须跟着进核心文件,否则会被静默丢弃(拆分脚本已把首个片段的起点设为 0)。
5. ⚠️ 内部头要带上原文件真正用到的全部头(DataModel 需要 `Theme.hpp`/`<cstring>`/`<cstdio>`/
`<deque>`/`<atomic>`/`<cwctype>`/`<new>`)。
6. ⚠️ **拆分不会减小机器码,反而略增**:实测 754 176 → 764 416 B(+10 240 B,+1.4%),
因为 `inline` 工具函数每个引用它的 TU 各生成一份(PE 无 ICF)。
#### 八、`LanePanelState` 抽取 + 错误提示非模态化(本轮补齐,同一轮次的第二遍)
1. **`Lane` 的 12 个平铺面板字段**(`edgePanelOpen/X/Y` × 4 类实验性功能)→ **4 个聚合成员**
`LanePanelState edgePanel / edgeCenterPanel / meanFilterPanel / weakPeakPanel`;
`LanePanelState { bool open=true; double x=-1; double y=-1; }`。
访问点全部改为 `lane.edgePanel.open` 形式(`DataModelTab.cpp` 快照拷贝、
`PlotViewInteraction.cpp` 拖拽、`PlotViewPaint.cpp` 面板层序绘制);
`RebuildLanes` 内部的 `LaneCarry` 同步收敛为 4 个 `LanePanelState`(逐字段赋值 → 整体赋值)。
⚠️ **教训**:批量改名脚本**不能对「声明」和「使用」跑同一组替换对** ——
`edgePanelOpen → edgePanel.open` 用在 `LaneCarry` 的**声明行**上会生成
`bool edgePanel.open = true;`(非法)。声明处必须单独重写。
2. **错误提示与模态阻塞解耦**:`Application::SetModalErrorsEnabled(bool)` + `ModalErrorsEnabled()`,
成员 `m_modalErrorsEnabled` 默认 `true`;`Initialize(instance, showCommand)` **最先**执行
`SetModalErrorsEnabled(showCommand != SW_HIDE)`(隐藏窗口下弹模态框无人能点掉 = 进程被钉死)。
`SetStatusMessage(text, isError=true)`:允许模态 → 原样 `MessageBoxW`;
不允许 → `m_plot.ShowPersistentHint(text)`(绘图区底部非模态浮层)。
普通提示与空文本仍被忽略 ⇒ **交互场景行为完全不变**。
`PlotView` 新增公开 `ShowPersistentHint(text)`(内部转 `SetHintSticky`),
不暴露粘滞/淡出细节。
3. ⚠️ **顺带修掉真实缺陷**:`PlotView::Paint` 在 `m_lanes.empty()` 时提前 `return`,
而提示浮层在其后的叠加层里绘制 ⇒ **一个信号都没勾选时任何错误都显示不出来**
(恰恰是"文件加载失败"最需要提示的场景)。修法:该分支补一次 `PaintHint(g)`。
这是**探针先失败、才回头发现的**。
4. **新增探针 `.preflight/perf/errcheck`(19/19)**:默认值/隐藏窗口降级/宿主显式开关;
`SetStatusMessage` 从**工作线程**调用须 5s 内返回(旧行为永久阻塞 ⇒ 判失败并 `ExitProcess`,
探针不会挂);`EnumWindows` 统计本进程 `#32770` 顶层窗口数量前后不变;
错误文本进 `m_hintText`、`m_hintSticky`、`HintNeedsAnimation()`、`m_hintShownAt != 0`;
**像素级**:有/无提示两帧在绘图区内的差异像素 3781,且**全帧差异也是 3781**(差异只来自提示)。
5. **验证**:`make release` / `make debug` 零警告,`Target.exe` **764 416 B**(结构改动,无新增膨胀);
`verify` 49/49、`datacheck` 69/69、`edgecheck` 46/46、`panelanim` 156/156、`errcheck` 19/19。
#### 九、本轮仍**未**完成(诚实记录)
1. 上一轮建议的 MF4 压缩块 / `##DL` 数据列表:按用户要求继续不做。
> ⚠️ 排查教训(写进 §11.2):**同一组批量改名不能同时覆盖「声明」与「使用」**;
> 并且**任何"宿主级反馈通道"都必须做像素级验证** —— 通道存在 ≠ 通道可见
>(`Paint` 的 early-return 会静默把它吃掉)。
---
### 9.38 五项交互修复:折起后滚动条 / 单点信号样式 / 线宽实时生效 / 网格菜单限高滚动 / 点选信号滚动绘图区(本轮,起源设备)
> 用户 2026-10-16 第四轮(起源设备)。
> 离屏验证:**`.preflight/check`(新建)68/68 通过**(`SW_HIDE` + 只注入合成消息 + 进程/线程 `BELOW_NORMAL`);
> **A/B**:五处修复全部回退 ⇒ **17 项如实失败**(B 4 / C 4 / D 7 / E 2),
> 其中「折起后滚动条」需要**逐帧重绘驱动动画**才能复现 ⇒ 单独回退时**5 项如实失败**;
> 恢复后 **68/68**;`make release` / `make debug` **零警告**。
> 另按要求**删除 `Tools/`(`structcheck` 脚本)并移除 `make structcheck` 目标**(见本节七)。
#### 一、分组折起后再展开,信号列表的滚动条消失(用户报告)
- **现象**:加载 MF4 → 折起分组 → 展开分组 ⇒ 列表右侧滚动条**消失且不再回来**(筛选态、普通态均可复现)。
- **根因**(两处叠加):
* `AdvanceGroupAnimations` 只在**折起**完成时 `m_rowsDirty = true`(认为“展开后行集合本来就是展开态”);
* `RefreshScrollBar()` 只在 `EnsureRows()`(行集脏)/`Layout()` 里被调用 ——
展开结束那一帧 `m_contentHeight` 从“收起态”跳回“展开态”,但**没有任何路径去重算滚动条可见性**。
* 于是 `m_scrollBarVisible` 停在动画开始时的 `false`、`m_listRect.Width` 停在“无滚动条”的整宽,
直到用户做别的动作(筛选 / 缩放窗口 / 勾选信号)才恢复。
- **修法**:`AdvanceGroupAnimations` 里 **任一方向的动画结束都 `m_rowsDirty = true` 并 `BuildRows()`**
(内部重算内容高度与逐行位移),随后**每帧都调 `RefreshScrollBar(false)`** ——
让滚动条可见性/列表可用宽度跟随动画中的内容高度实时更新。
- ⚠️ `RefreshScrollBar` 新增 `clampScroll` 形参(默认 true);**动画期间必须传 false**:
与滚动同步的折起动画(`scrollSynced`)正按自己的进度推进 `m_scrollOffset`,夹取会打断它。
- ⚠️⚠️ **探针教训**:必须**逐帧重绘**(每帧 `Render()`)来驱动这个动画,
否则 `EnsureRows()` 会在动画结束后顺手修好“完成帧的不一致”,把缺陷**掩盖**掉
(第一版探针就是这样假绿的)。
#### 二、单点信号:默认绘制方式改「散点」+ 「线段」/「点线」置灰(用户要求)
- `ApplySinglePointDefaultStyle`:`DrawMode::LINE_SCATTER` → **`DrawMode::SCATTER`**(仍取最大线宽)。
- 新增 `SignalPanel::ModeOptionEnabled(optionIndex)`:**只有一个有效采样点**的信号只允许「散点」,
「线段」「点线」**置灰**(弱色文字)**且不可点**、**不参与悬停高亮**(避免“看着能点其实点不动”)。
- `PlotView::UpdateAutoScatterStyles` **跳过单点信号**(`finiteCount == 1`):
否则“放大自动点线”会把它又改回「点线」,与置灰状态自相矛盾。
#### 三、拖动线宽滑块完全不生效(用户报告)—— **根因是折线缓存键漏了线宽**
- **现象**:拖动线宽滑块时信号线宽**纹丝不动**;松开左键的瞬间变了;再移动鼠标又**变回初始线宽**。
- **根因(两个,都要修)**:
1. ⚠️⚠️ **`PlotView::AcquirePolyline` 的缓存键没有 `lineWidth`**,而 `BuildPolyline` 会把线宽
**烘进包络带几何**(`strokeHalf = max(1, 线宽) * scale * 0.5`,逐列上下包络各向外扩这么多),
「梳状」判定也依赖扩展后的列跨度 ⇒ **稠密信号(采样数 > 绘图区宽度)改变线宽时命中旧条目**、
永远按旧线宽绘制(顶点、包络带全部复用)。实测常量信号:`0.5px → 5.3px → 10px` 时
绘图区列厚度始终是 **1px**。
2. **样式补丁没有同步宽基底图层**:补丁只更新了“静态场景缓存 + 后备缓冲区”,
而每网格图层仍是旧样式像素;下一帧命中“宽基底快路径”时会把新样式**覆盖回旧像素**
⇒ 这正是用户看到的“松手后变了、移动鼠标又变回去”。
- **修法**:
* `PolylineCacheEntry` 新增 `lineWidth` 并进比较(凡是**被烘进几何**的样式都必须进键);
* 样式补丁分支在 `RepaintSceneCachePatch` 之后 **`QueueLaneRebuild(受影响网格) + RenderPendingLaneRebuilds(1)`**
—— 按既有预算机制(每帧至多 1 个网格)把宽基底同步到新样式,不在交互帧里做整批重建。
- **实测**(`.preflight/check`,常量信号 + 逐帧测量信号色像素):
`0.5px` → 列厚 **1px**;`5.3px` → **7px**;`10px` → **12px**;松手后 **12px 保持**;再移动鼠标 **仍 12px**。
#### 四、「目标」行 GX 标签复用「所属网格」菜单 + 限高滚动 + 底部固定项(用户要求)
- **入口**:「目标」行(样式区)的 GX 标签单击 ⇒ 展开与信号列表行末尾 GX 徽标**完全同一份**菜单
(`SignalPanel::OpenLaneMenu`;按下只登记 `m_pendingTargetLaneMenu`,**抬起仍在同一徽标上**才展开)。
- ⚠️ GX 标签点击**不做**联动滚动(只有信号名点击才滚动);**悬停联动仍然保留**(`StyleHit::TARGET_BADGE`)。
- **`PopupMenu` 新增「限高 + 条目区滚动 + 尾部固定」能力**(`MenuLayoutConstraint::maxHeight`):
* 菜单最高 ≈ 客户区高度的 62%(`OpenLaneMenu` 传入);
* **固定区 = 最后一个分隔线及其之后的条目**(即「新建网格」+ 它上面的分隔线),
始终钉在菜单底部(`m_pinnedIndex` / `m_pinnedTargetTop`);其余条目在滚动区内;
* 滚轮滚动(`PopupMenu::OnMouseWheel`;`Application` 在菜单内滚动时**不关闭菜单**),
键盘方向键移动悬停项时自动把它滚入可视区(`EnsureItemVisible`);
* ⚠️ 滚动区文本必须用 **`UiTextClipped`**(GDI 文本不遵循 GDI+ 的 `SetClip`),
并**跳过完全不可见的条目**(否则上百条逐条绘制照样卡顿);
* ⚠️ `ItemAtPoint` 要求滚动区条目**同时命中滚动视口**(夹在边界上的部分可见条目只有可视部分可点);
* ⚠️⚠️ **`BuildLayout` 不得无条件清零 `m_scrollOffset`**:`RebuildLayout()`(滚轮 / 数值回写触发)
会重走一遍本函数,清零就等于把滚动位置吃掉(滚轮“滚不动”,实测踩到)。
#### 五、点选信号 ⇒ 绘图区自动把目标网格滚到可视区内(用户要求)
- **背景**:把 MF4 的上百条信号各自放进独立网格时,绝大多数网格在可视区之外,
仅靠网格高亮无法定位到画面之外的网格。
- **链路**:`SignalPanel::RequestPlotLaneScroll(ref)`(→ `m_pendingPlotLane`)
→ `Application::ApplyPendingPlotLaneScroll()`(`WM_LBUTTONDOWN` 的面板分支里调用)
→ `PlotView::ScrollLaneIntoView(laneIndex)`。
- **触发条件**:**点击信号名**(列表行主体 / 样式区「目标」行的信号名)。
⚠️ **悬停不滚动**(只保留既有网格高亮联动);勾选框 / GX 徽标 / 移除 ✕ 也不滚动。
- **规则**:目标网格在可视区上方 ⇒ 向上滚到它的上边界贴绘图区上沿;在下方 ⇒ 向下滚到它的下边界贴下沿;
已完整可见 ⇒ **不动视图**(避免无意义改动)。
#### 六、验证(全部离屏,不占用鼠标 / 键盘 / 屏幕)
- 新增 `.preflight/check/{main.cpp,build.ps1}`(不入库):真实 `Application`(`SW_HIDE`)+ 合成消息注入,
六个断言组:**[A]** 折起/展开与滚动条、**[B]** 单点信号样式与置灰(含“悬停置灰按钮像素零变化”)、
**[C]** 线宽拖动逐帧像素度量、**[D]** 「目标」行 GX 菜单(限高 / 滚动 / 固定项 / 命中 / 复用换网格)、
**[E]** 点选信号滚动绘图区(含“悬停不滚动”与“已可见不动”)、**[F]** 回归(列表行徽标菜单、向上滚动、筛选后滚动条)。
- 结果:**68/68 通过**;`make release` / `make debug` **零警告**。
- **A/B 反证**:五处修复全部回退 ⇒ **17 项如实失败**(B 4 / C 4 / D 7 / E 2);
A 组(滚动条)因探针需要**逐帧重绘**才能复现,单独回退时 ⇒ **5 项如实失败**。
#### 七、按要求移除远端协助设备的测试工具(用户明确要求)
- 删除工作区 `Tools/`(`structcheck.ps1` / `structcheck_phrases.txt`)并移除 `Makefile` 的 `structcheck` 目标
(该工具只是另一台设备上次为“多文件拆分”验证一次性上传的)。
- ⚠️ §9.37 / §11.2 第 96 条里凡是“`make structcheck` 可复跑”的描述**自此失效**,
仅作为当时那一轮拆分的**历史证据**保留;后续若要再加结构检查,需要重新上传脚本。
---
### 9.39 「所属网格」菜单:标题固定 + 10 行上限 + 滚动条,以及网格滚动联动改为动态过渡(本轮,起源设备)
> 用户 2026-10-16 第五轮(起源设备)。
> 离屏验证:`.preflight/check` **95/95 通过**;**A/B**(回退顶部固定 + 回退 10 行上限 + 回退滚动动画)
> ⇒ **10 项如实失败**;恢复后 **95/95**;`make release` / `make debug` **零警告**。
#### 一、菜单滚动时标题不再跟着滚(用户报告)
- **现象**:网格数很多时,滚动「所属网格」菜单的条目区,最上方的“所属网格”**标题也一起被滚走**。
- **根因**:上一轮只实现了“**尾部**固定区”(最后一个分隔线 + 「新建网格」钉底),
滚动区是从**条目 0** 开始的 —— 标题自然跟着滚。
- **修法**:`BuildLayout` 新增 **`m_scrollFirst`**(= 前导 `HEADER` / `HEADER_ACTION` 的个数),
滚动区变为半开区间 **`[m_scrollFirst, m_pinnedIndex)`**:标题从菜单顶向下固定排(`headCursorY`),
滚动区条目才受 `m_scrollOffset` 影响,尾部固定区从菜单底向上对齐。
- 配套更新:`Paint` 的裁剪/跳过范围、`ItemAtPoint` 的“必须落在滚动视口内”判定、
`EnsureItemVisible` 的下界,都改用 `[m_scrollFirst, m_pinnedIndex)`。新增 `ScrollRegion(first, last)` 供查询/测试。
#### 二、菜单长度上限改为「鬃 10 条网格」+ 滚动条(用户要求)
- `MenuLayoutConstraint` 新增 **`int maxScrollRows`**(滚动区最多显示的标准行数)——
比 `maxHeight` 更直观(“保留 10 条网格的上限”)。两个约束同时给出时**取更严格者**:
`viewportLimit = min(maxHeight 换算的可用高, maxScrollRows × 标准行高)`。
- `SignalPanel::OpenLaneMenu` 现在传 `maxScrollRows = kLaneMenuVisibleRows(=10)`
(另外保留 `maxHeight = max(180, 客户区高 × 0.8)` 作为矮窗口的硬上限)。
- **新增滚动条**(用户要求):轨道 `m_scrollBarRect` 贴在**滚动条目区**的右内侧(不盖住标题与底部固定项),
滑块 `ScrollBarThumbRect()`(高度按视口/内容比,下限 `kScrollBarMinThumb=22`);
滚动区的条目宽度**让出** `kScrollBarWidth + kScrollBarGap`,文本与对勾不会压在滑块上。
- **滚动条可拖拽**:`OnLButtonDown` 命中滑块 → 开始拖拽(`m_scrollBarDragging`);
命中轨道空白 → 把滑块中心移过去;`OnMouseMove` 拖拽中只调整偏移、**不更新悬停**;
`OnLButtonUp` 结束拖拽且**不下发命令、菜单保持打开**。新增 `ScrollOffsetForThumbTop()` 反算偏移。
- ⚠️⚠️ **`BuildLayout` 绝不能复位 `m_scrollBarDragging`**:拖拽时每帧都会 `RebuildLayout()`,
在那里清零会把拖拽态**当场吃掉**(症状:按住滑块只能拖一下、随后就“脱手”)。
#### 三、网格滚动联动改为**动态过渡**(用户要求:旧实现“瞬移”太僵硬)
- **旧行为**:`PlotView::ScrollLaneIntoView` 直接 `Tab().LaneScroll() = desired` —— 视界瞬移过去。
- **现行**:与信号列表的“滚动到目标信号”**同源**的平滑过渡:
* `m_laneScrollAnimActive/From/To/StartMs/DurationMs`;
* 时长 = `clamp(距离 × 0.35ms, 130, 300)`,进度再过 **ease-out cubic**;
* 宿主 16ms 定时器(`kScrollTimerId`)调用 `AdvanceLaneScrollAnimation()`,与面板动画同一条定时器;
* `Render` 的“需要保持定时重绘”判定加入 `m_plot.LaneScrollAnimationActive()`;
* 每帧 `ClampLaneScroll()` + `BuildLaneVisuals()`(网格滚动量参与场景/图层指纹与各网格 rect)。
- ⚠️ **用户自己滚动网格(滚轮 / 拖滚动条)必须 `CancelLaneScrollAnimation()`**,
否则动画会与手动滚动抢滚动位置;`ScrollLaneIntoView` 对已完整可见的网格仍直接返回 false(不启动动画)。
- 实测:154 个网格、距离 12091px 时动画 **20~21 帧**完成(≈300ms),中间帧滚动量单调递增、
结束时**精确落到目标**且目标网格上下边界都在绘图区内。
#### 四、验证
- `.preflight/check` 新增断言:菜单**顶部标题在滚动时不动**、滚动视口高度恰为 **10 × 行高**、
滚动条轨道/滑块存在且随滚动移动、**按住滑块拖到底**(菜单不关、不下发命令、末条目入视口)、
滚轮回到顶部;`ScrollLaneIntoView` **不瞬移**(刚点击时几乎没动)、递进若干帧后精确到位、
已可见时**不启动动画**、滚轮/手动滚动会取消动画。
- **A/B**:回退三处(标题固定 / 10 行上限 / 滚动动画)⇒ **10 项如实失败**;恢复后 95/95。
---
### 9.41 回退 `5b52eef` 并修复「屏幕不刷新」根因(拖动滚动条卡顿 / 悬停高亮卡住 / 信号拖拽无效果)(本轮,本机)
> ⚠️⚠️ **本轮撤销提交 `5b52eef`(§9.40 随之作废,作废范围见下与 §11.2 的编号注记)。**
> 用户报告:以加载 `TestData/MF4-Record.MF4` 并显示全部信号为例 —— ① 拖动网格右侧滑动条非常卡顿、
> 几乎拖不动;② 悬停到某条信号触发高亮后,鼠标移开(不落到另一条曲线上)高亮**仍然卡住**;
> ③ 几乎无法正常拖拽信号到其它网格;④ 预期的流畅动画帧没有实现。用户要求「撤销上次的提交并着手定位并修复」。
#### 一、根因:`Render()` 的回送被写成「两个条件都不满足 ⇒ 一帧都不回送」(代码级确认)
`Application::Render()` 把整个界面画进后备缓冲区 `m_backDc`,最后 `GetDC` + `BitBlt` 回送窗口。
`5b52eef` 把它改成了:
```cpp
if (fullFrame) { BitBlt(windowDc, 0, 0, width, height, m_backDc, 0, 0, SRCCOPY); }
else if (panelOnly) { /* 只回送“本帧重绘过的区域并集” */ }
// ← 再也没有兜底 else
```
而 `fullFrame = chromeDirty || overlaysVisible || m_plot.HintNeedsAnimation()`,
`panelOnly = !chromeDirty && (m_panelDirty || panelAnimating)`。
**「鼠标在绘图区内移动」这一类帧恰好满足 `fullFrame == false && panelOnly == false`**
(`WM_MOUSEMOVE` 的 `plotOnlyMove` 路径只 `InvalidatePlotRect`,既不动 `m_chromeDirty` 也不动 `m_panelDirty`)
⇒ **后备缓冲区被正确重绘,窗口 DC 一个像素都没更新。**
这**一个根因**同时解释了用户报告的三条症状:
* 悬停光晕已画进后备缓冲区却不回送 ⇒ 屏幕停在旧像素 ⇒ **“高亮卡住、移开不消失”**;
* 拖动滚动条时滑块与波形都不回送 ⇒ 只有偶尔整窗失效(定时器 / 装饰脏)那一帧才刷新
⇒ **“非常卡顿 / 拖不动”**;
* 拖拽信号换网格后不回送 ⇒ 用户看不到任何结果 ⇒ **“几乎无法拖拽”**。
(§9.40 想省下的“面板动画帧重画绘图区的 ~5ms”,代价就是这条正确性,得不偿失。)
#### 二、修法:整体撤销该机制,回到「整窗失效 + 无条件兜底回送」
- `git reset --hard HEAD~1` 撤销 `5b52eef`(先打标签 `backup-bad-commit` 供 A/B)。
- **只挑回该提交里“与更新区域无关”的**功能改动(它们本身是对的,用户也明确要求过):
- `SignalPanel::ExpandGroupForSignal()`(联动滚动前**立刻展开**折起的分组,不播展开动画);
- `FitAll()` / `FitSinglePointCluster()` **不再复位** `Tab().LaneScroll()`(“适应视图不影响滚动条”);
- 右下角「回到最上方」浮动按钮(`BackToTopRect` / 170ms 淡入淡出 / `ScrollLanesToTop` / `PaintBackToTop`)
与「网格滚动到可视区」的平滑动画(`ScrollLaneIntoView` 只启动、`AdvanceLaneScrollAnimation` 推进、
手动滚动时 `CancelLaneScrollAnimation()`)。
- 宿主 `Application.cpp` 只保留 **3 处最小改动**:`Render` 里把 `panelAnimating` 计入整帧脏;
`WM_TIMER` 推进 `AdvanceBackToTopAnimation()` 并**保持整窗失效**;定时器存活判定带上
`BackToTopAnimationActive()`。**`Render` 的绘制与 BitBlt 路径逐字节等同于 `82b5ebe`。**
- **彻底删除** `m_paintPlot` / `m_panelDirty` / `panelOnly` / `InvalidatePanelRect` / 并集回送。
#### 三、验证(全部离屏,不占用用户的鼠标 / 键盘 / 屏幕)
- **`.preflight/diag`(本轮新增,19/19 通过)**:把窗口 `MoveWindow` 到屏幕外(`-30000,-30000`)
+ `WS_EX_TOOLWINDOW` + `SW_SHOWNOACTIVATE` ⇒ **窗口真实可见、有真实更新区域与 `WM_PAINT`,
但完全不出现在用户屏幕上、不抢焦点、不进任务栏**。四项断言:
**[A]** 后备缓冲区可读;**[B]** 拖动网格滚动条 ⇒ 滚动量变化 + **滑块与绘图区都在后备缓冲区里被重绘**;
**[C]** 悬停曲线 ⇒ 进入悬停态且绘图区像素变化(光晕可见),移开 ⇒ 悬停态清除且**像素完全回到基线**;
**[D]** 在曲线上按下进入 `MOVE_SIGNAL`(**不是** `LANE_REORDER`)、拖到另一网格松手后该信号归属正确。
- **`.preflight/check` 137/137**(原 [J]「面板动画帧跳过绘图区」改写为「面板动画帧**必须**重绘、
且不把绘图区画坏」,并新增“只改绘图区的帧仍必须在后备缓冲区里更新”)。
- **`.preflight/diag/verify_blit.ps1`(本轮新增,源码级护栏,10/10 通过)**:断言 `Render()` 的回送块里
**必须有无条件 `else`**、**不得出现 `else if`**、`m_paintPlot`/`m_panelDirty`/`panelOnly` **必须不存在**、
失效方式只允许“整窗”或“仅绘图区”。
- **A/B**:把 `Application.cpp` 换回 `backup-bad-commit` ⇒ 护栏 **4 项如实失败**;换回修复版 ⇒ 10/10。
- `make release` **零错误零警告**。
#### 四、⚠️ 方法论教训(已写进 §11.2 第 108–116 条)
1. **离屏探针读不到“窗口回送”**:窗口在屏幕外时 DWM 不会合成,`GetDC` 读回的窗口 DC 恒为空白
⇒ “画进了后备缓冲区但没回送”的缺陷在离屏探针里**永远假绿**(本轮就是这样假绿的)。
凡是**回送 / 合成**相关的约束,必须有**源码级护栏**兜底。
2. **离屏窗口 + `TrackMouseEvent` 的陷阱**:产品在 `WM_MOUSEMOVE` 里注册 `TME_LEAVE`,
而离屏窗口上真实鼠标并不在它上面 ⇒ 系统**立刻**兑现一条 `WM_MOUSELEAVE`,刚设置的悬停态
马上被清掉、探针永远观察不到悬停效果。探针的消息泵必须**丢弃 `WM_MOUSELEAVE`**(仅限探针,不改产品)。
3. **对“网格会被回收 / 下标会左移”的断言必须换成锚信号**:`MoveSignalToLane` 后源网格若被移空会被回收,
其后所有网格下标**左移**(本轮实测 124 → 123)⇒ 「移动后下标 == 目标下标」会假失败。
4. **失败时先怀疑探针自己的取点与判据**:本轮先后误判“悬停不生效”“信号没换网格”,
实际都是探针取点踩到了「GX 重排徽标 / 图例 / 时间光标线」这些**优先级更高的热区**,
或用了错误的比较基准(拿滚动后的 rect 当比较区域)。
5. `Copy-Item` **保留时间戳** ⇒ 用备份覆盖源文件后必须 `(Get-Item f).LastWriteTime = Get-Date`
再 `make`,否则测的还是旧目标文件(本轮 A/B 又踩了一次,与 §9.40 同)。
6. 不要对**源码文件**做 PowerShell 文本往返(`Get-Content`/`Set-Content`):
PS 5.1 按 ANSI/GBK 解码 UTF-8,会把 C++ 源里的中文注释**整份写坏**并加上 BOM。
只能改用文件编辑工具改源文件(本轮就这样毁过一次 `.preflight/diag/main.cpp`)。
---
### 9.42 网格底衬永不空白 + 动画流畅性根治 + 光标随首个信号重现 + 「放大自动点线」新判据(本轮)
> 用户 2026-10-17 第六轮(本机)。四项需求:
> ① 选区放大 / 适应视图后网格底衬**消失(半屏或局部)**、要等一会才刷新好;
> ② 所有过渡动画**卡顿 / 不平顺 / 直接闪到终态**(折起展开、网格滚动、图例联动、回到最上方按钮);
> ③ 光标工具激活且**没有任何信号绘制**时,首个信号出现后光标要**按规则重新出现**(不再停在旧残留位置);
> ④ 「放大自动点线」规则改为:**存在任何一个小格内数据点少于 5 个**即自动切换。
> 离屏验证:新增 `.preflight/perf/gridanim_main.cpp`(`gridanim.exe`,**55/55**,含**真实消息循环**计时);
> 既有 `verify` **49/49**、`panelanim` **156/156**、`datacheck` **69/69**、`edgecheck` **46/46**、
> `errcheck` **19/19**;`make release` 零警告。
#### 一、网格底衬消失:**旧跨度图层的 `StretchBlt` 占位拉伸** + 预热按下标顺序推进
- **两个机制叠加**(都能单独造成用户看到的现象):
1. `BlitWideBuffer` 对「跨度已过期」的图层(缩放瞬间)原用 `StretchBlt` 把旧图层**放大**占位 ——
深度放大时那是把一小段像素放大几十倍 ⇒ 整块网格变成**一片无网格的模糊色块**,
用户眼里就是「网格叠底消失、没有任何内容」。局部/半屏/全屏取决于有多少个网格处于过期状态。
2. 待重画队列(`m_layerRebuildPending`)**按下标顺序**消费,一次只补 1 个网格;
上百个网格时,用户正在看的那些(往往在下半部)可能要等**上百帧**才轮到
⇒「需要等一会网格底衬和信号才正确加载」。
- **修法**:
* **删除占位拉伸**;`BlitWideBuffer` 对「拿不到(完整)图层像素」的网格**就地补画网格底衬**
(`PaintLaneGridBacking` → 复用 `PaintLane` 的网格部分,新增 `m_renderGridOnly` 跳过
波形/散点/派生曲线/Y 轴刻度/图例)。底衬代价极低,可**每帧**执行 ⇒ 网格永不空白/闪烁。
⚠️ 顺序必须**先画底衬(GDI+)再取 HDC 做 BitBlt**(`g.GetHDC()` 之后不能再调 GDI+)。
⚠️ 只有**所有**网格都贴不出像素时才 `return false`(交给整幅重绘),否则会停在「只有底衬、没有波形」。
* 队列消费改为**可视网格优先**(`std::stable_partition` + `LaneVisuallyRelevant`)⇒ 可视区域 ~10 帧内收敛。
* 「结算」(整幅精确帧)判据由 `LanesAllReadyForView()` 改为 **`LanesVisiblyReadyForView()`**:
上百个网格时不必等全部**离屏**图层就绪(否则结算被拖到上百帧之后)。
#### 二、动画卡顿根因之一:**「队列非空就请求再送一帧」造成的高频重绘饿死动画定时器**
- **现象**:用户循环「勾选分组复选框(全部信号→124 个网格)→ 折起/展开分组」时动画卡住/闪烁。
- **根因**:设备队列里长期残留大量**离屏**网格 ⇒ `WarmLaneLayersStep` / 宽基底分支按
「`m_layerRebuildPending` 非空」**每帧**都 `PostMessage(kMessagePlotDerivedReady)` ⇒ 700ms 内挤出
**124 次重绘**;而 `WM_TIMER` 的优先级**低于投递消息** ⇒ 动画定时器被饿死。
**实测(探针)**:修复前该场景 `frames=124 / timerTicks=1`(动画几乎没动);修复后 `frames=25 / timerTicks=16`。
- **修法**:新增 `AnyVisibleLanePending()`,只用「**还有可视网格待补**」决定是否请求再送一帧;
离屏网格改为**机会式**预热(任何一帧顺手补一个)。
#### 三、动画卡顿根因之二:**16ms 定时器被系统 tick 量化成 ~26ms**(只有约 38fps)
- **现象**:所有过渡动画「不平顺、像掉帧」。用户要求「找出一个能精准评估动画流畅性的方式」。
- **方法**:探针**子类化宿主窗口**(`SetWindowLongPtrW(GWLP_WNDPROC)`)记录每个 `WM_PAINT` / `WM_TIMER(id=3)`
的时刻,跑**真实消息循环**(真定时器、真 `WM_PAINT`)并统计帧距均值/最大。窗口需
「离屏但可见」(`-30000,-30000` + `WS_EX_TOOLWINDOW` + `SWP_SHOWWINDOW|SWP_NOACTIVATE`),
否则隐藏窗口没有 `WM_PAINT`。
- **实测(关键数据)**:请求周期 → 实际帧距:**8ms→15.8 / 10ms→15.5 / 12ms→16.5 / 16ms→26.4 / 31ms→37.9**。
⇒ `SetTimer(...,16,...)` 会被量化到**下一个**系统 tick(默认 15.6ms),实际 ~26ms(≈38fps)。
- **修法**:`kScrollTimerPeriodMs` **16 → 10**(量化结果正好一个 tick,实测 ~15.5ms ≈ 64fps);
实测真实循环帧距 ~25ms → **~16ms**,动画恢复平滑。`timeBeginPeriod(1)` 在本机**无效果**(已验证)。
- 另一处:**网格滚动联动 / 回到最上方**每帧都在改 `LaneScroll()`,而它原本参与
「宽基底可用性」判据(`SceneGeometryHash`)⇒ 每一帧都退化成**整幅重绘**。
修法:`SceneContentHash` 新增 `includeLaneScroll` 形参;**静态场景缓存仍计入**它(缓存位图内容取决于它),
但**宽基底可用性判据不再计入**(网格滚动只改各网格的**贴图落点**,图层内容完全不变,
`BlitWideBuffer` 本来就按 `lane.rect` 逐网格贴图)。
**实测**:网格滚动动画单帧 **18.36ms → 1.19ms(均值)/ 23.02ms → 6.83ms(最坏)**;可跑满 60fps。
#### 四、光标:没有任何信号绘制时,首个信号出现后**按规则重新出现**(用户要求)
- `PlotView::ResetCursorPlacementOnFreshContent()`:规则与 `CycleCursorMode()` 的「首次出现」**完全同源** ——
单光标置于视图中心;双光标第一条置于视图中心、第二条紧邻第一条且保证落在可见范围内。
- 接入点:`Application::SynchronizeView` 的 `else if (!tab.ViewInitialized())` 分支
(= 「刚才没有任何可见信号、现在首次出现」)在 `FitAll()` **之后**调用它。
- 实测:单光标 stale=4.60 → 新位置 = 视图中心 20.92;双光标 stale=(2.89, 7.18) → (20.92, 26.07)(均与中心/邻位一致)。
#### 五、「放大自动点线」新判据:**任一小格内数据点 < 5**(用户 2026-10-17 重新定义)
- **推翻**此前的「一屏内数据点 < N×4」(`SignalSnapEnabled`)。新判据 `PlotView::AutoPointLineNeeded`:
按 `GridMinorCellCount()` 把视界切成 N 个**横向小格**,**只要存在任何一个小格内的有效数据点 < 5**
就自动切「点线」(更贴近用户意图:**局部**稀疏也提示)。
- 实现要点:① 先用「有效点总数 < N×5」做 **O(1) 鸽巢充分判定**;
② 时间轴单调时每个小格用**两次二分**取样本区间(代价与采样数无关,单格扫到 5 个有效点即早退,
再给 `kAutoPointLineCellScanCap=512` 封顶);③ 非单调时一次有界扫描按小格分桶。
④ 保留「视界内至少有一个可见数据点」的保护(否则整屏无点时会被判成稀疏而白切一次)。
- ⚠️ **光标吸附仍用旧的整屏判据**(`SignalSnapEnabled`):用户明确要求「其它规则不变」——
§9.30 记的「自动点线 / 吸附同源」**自本轮起不再成立**。
- 实测(探针写入 20001 点 / [0,1000] 的单信号):全视图(span=1000,50 小格,每格 400 点)⇒ 不切换;
放大到 span=2.5(每格 1 点)⇒ 切换并置 `autoScatterActive`;`FitAll()` 回到稠密 ⇒ 恢复原样式。
#### 六、⚠️ 本轮方法论教训
1. **`theme.cardBg == theme.plotBg`(都是 kBase3)**⇒ 任何「按颜色区分『空白/已绘制』」的探针判据
都是**色盲**的(实测假绿)。判断「网格是否画出来」只能用**结构判据**(竖直锐边)。
2. **竖直锐边判据必须要求「同一列多行都有锐变」**:网格**分界线**是一条横线,会让每一列都"看起来有锐边"
(第一版 `SharpColumns` 因此假绿)。改为「同一 x 至少 3 行有 |Δ|≥阈值」后才有判别力。
3. **后备缓冲区是持久的**:宽基底路径只把绘图区填成 `plotBg`(== `cardBg`)⇒ 本帧没被覆盖的网格
**保留上一帧像素**(陈旧但"看起来有内容")⇒ 探针里"没有空白"**不能**证明没问题。
要证伪必须**注入一个刺眼占位色**(本轮试过品红)或直接对比两张图。
4. ⚠️ **探针里动画计时必须在启动动画后先画一帧**:`Render` 才负责 `SetTimer` 武装动画定时器;
否则消息循环无消息可等 ⇒ `frames=0`(本轮假失败两次)。
5. ⚠️ 构建 25 个源文件的探针约 **43 秒**;`build.ps1` 若失败只在 `$LASTEXITCODE` 上体现,
直接跑旧 exe 会拿到**旧结果**(本轮因此误判过一次)—— 必须检查构建输出。
---
### 9.43 滚动过渡动画根治 + 快速连点跟手 + 分组名单击展开 + 光标吸附同源 + 按钮淡出对齐 + 滚动位置复位(本轮)
> 用户 2026-10-17 第七轮(本机)。六项需求:
> ① 鼠标拖光标时**鼠标下方网格内**按「进入自动点线」**同一判定**的数据点吸附(用户改过显示样式也吸附);
> ② 滚动过渡动画(点选最后一个信号 / 「回到最上方」)卡顿,甚至不如手拖滚动条;
> ③ 「回到最上方」按钮的**渐变消失**要和「选区放大浮动提示」一致;
> ④ 「组X」快速点击不跟手 + 分组**名称**恢复单击展开收起(只有**快速双击**才进入改名);
> ⑤ 所有复选框 / 「全部显示隐藏」/ 「绝对值」快速点击不跟手;
> ⑥ 「有过需滚动条的网格 → 没有信号 → 又出现需滚动条数量的网格」时滚动条**回到最上方**。
> 离屏验证:`gridanim` **93/93**(含真实消息循环帧距 / 逐帧渲染耗时对比);`verify` 49/49、
> `panelanim` **158/158**(按新规则更新 8 条断言)、`datacheck` 69/69、`edgecheck` 46/46、`errcheck` 19/19;
> `make release` / `make debug` 零警告。
#### 一、滚动过渡动画卡顿 —— **三处独立根因**(用「与手动拖滚动条对比」定位)
新增评估手段(用户要求「找出精准评估动画流畅性的方式」):**同一个二进制内对比**
「手动拖滚动条逐帧渲染」与「动画帧渲染」,并用**真实消息循环**统计帧距/逐帧耗时。
1. ⚠️⚠️ **`WM_TIMER` 的失效范围把绘图区动画变成「整窗重绘 + 整窗回送」**:
旧实现无条件 `InvalidateRect(hwnd, nullptr, FALSE)` ⇒ `WM_PAINT` 看到更新区域**不在绘图区内**
⇒ 把 `m_chromeDirty` 置真 ⇒ **每帧重绘标签栏/工具栏/面板**。实测纯绘图区帧 **4.4ms → 8.6ms(带整窗装饰)**,
「回到最上方」动画帧 **13.6ms → 3.5ms**(修好后)。
✓ 修法:用**推进之前**的面板动画状态决定失效范围 ——
* 面板动画(滚动到目标信号 / 分组折起展开)**确实**要改面板像素 ⇒ 整窗失效;
* 只有绘图区动画(网格滚动到可视区 / 回到最上方)⇒ **只失效绘图区**
(`WM_PAINT` 判定为“更新区域在绘图区内” ⇒ `m_chromeDirty` 保持 false ⇒ 只重绘、只回送绘图区)。
⚠️ 这不违反 §9.41:回送分支仍是 `if (fullFrame) {整窗} else {绘图区}`,**未改成“按更新区域决定画什么”**。
2. ⚠️⚠️ **图层预热不足 ⇒ 快速滚动时每帧要靠「网格底衬兜底」重画十几份网格**:
「全部信号各自一块网格」时待重画队列有上百个**离屏**网格。上一轮为避免高频重绘饿死动画定时器,
把「是否再请求一帧」限定为「还有**可视**网格待补」⇒ 离屏网格长期不建图层;
一旦用户点最后一个信号(长距离滚动动画)扫进这片区域,**每一帧都有十几个网格没有图层像素**,
只能就地补画网格底衬 —— 实测 **+8ms/帧**(`BlitWideBuffer()` 只画网格不打波形,代价 ~0.5ms/网格)。
✓ 修法(两步):
* `LaneVisuallyRelevant(laneIndex, prefetchScreens)`:新增**预取带**(±1 屏高),
`AnyVisibleLanePending()` 用它 ⇒ 静置时把**附近**网格的图层先建好;
* `PrefetchStepAllowed()`(`kPrefetchMinIntervalMs = 32`):队列里只剩离屏网格时,
以约 31 次/秒**限速**继续请求重绘 ⇒ 整批图层在静置期间(130 个网格约 4 秒)逐步建好,
同时不会把动画的 `WM_TIMER`(15.5ms 周期)挤出预算。
**实测(相同二进制、相同起始状态)**:`E4b lane-only+big` 帧 **13.9ms → 5.3ms**;
`E4 signal-click+big` **18.3ms → 10.3ms**。
3. 「网格滚动到可视区 / 回到最上方」每帧都在改 `LaneScroll()`,而它原本参与「宽基底可用性」判据
⇒ 每一帧都退化成整幅重绘(上一轮已修:`SceneContentHash(..., includeLaneScroll=false)`)。
**最终实测(124 网格;手动拖滚动条 = 参照)**:
| 场景 | 修前 | 修后 |
| --- | --- | --- |
| 手动拖滚动条(参照) | 6.6ms | **4.2ms** |
| 「回到最上方」动画 | 13.6ms / 帧距 22.6ms | **3.5ms / 帧距 15.6ms** |
| 点选最后信号(面板+绘图区,含 VERY_BIG_DATA)| 18.6ms | **10.3ms** |
| 点选最后信号(仅绘图区,含 VERY_BIG_DATA)| 14.8ms | **5.3ms** |
⚠️ 「点选最后信号」的剩余 10.3ms 里约 4.5ms 是**面板动画必须重绘面板+工具栏**的固有代价
(`E1d` 直接驱动同一套帧 ≈ 7.8ms,说明绘制本身在预算内,残余来自消息循环/调度)。
#### 二、光标吸附改用「自动点线」的**同一判定**(用户 2026-10-17 修订)
- `AutoPointLineNeeded` → 改名 **`MinorCellSparse`**(中性名,**同一判定两处用途**):
「放大自动切点线」(`UpdateAutoScatterStyles`)与「光标吸附」(`SnapTimeToSample`)。
- 判据:任一小格内有效数据点 < `kSparseMinPointsPerCell`(=5);
⚠️ 判定**完全不看绘制方式** ⇒ **即使用户手动改过显示样式也照样吸附**(用户明确要求)。
- 网格内**既有符合又有不符合**的信号时,只有**符合**的那条的数据点进入比较;
网格内**没有**符合条件的信号时,光标**完全自由移动**。
- 删除旧的整屏判据 `SignalSnapEnabled` 与 `kSnapVisibleCellsPerPoint`(成为死代码)。
- ⚠️ §9.30 记的「自动点线 / 吸附同源」在本轮**重新成立**(判据换成了小格判据)。
#### 三、「回到最上方」按钮淡出与「浮动提示」一致(用户要求)
- `kBackToTopFadeOutMs = kHintFadeMs`(500ms)+ **线性** alpha;到达顶部**立即开始**淡出(不停留)。
- 淡入保留原来的 `kBackToTopFadeInMs`(170ms,ease-out)—— 用户只要求淡出对齐。
- 实测:50% 时刻 alpha = **0.500**(线性),旧 ease-out 会是 0.875。
#### 四、快速连点跟手:`WM_LBUTTONDBLCLK` 必须当作一次「按下」(根因,覆盖全部控件)
- ⚠️⚠️ 窗口类带 `CS_DBLCLKS`:**快速连点同一个控件**时,第二次(及之后)按下会被 Windows 合成为
`WM_LBUTTONDBLCLK`。旧实现只给**工具栏**打了补丁(§9.32),其余控件(复选框 /「全部显示隐藏」/
「绝对值」/「组X」/ 折叠三角 / ✕ / GX 徽标 / 浮动面板上的复选框…)的那一次按下**完全丢失**
⇒ 用户看到的正是“连点好几下、状态只切换了几次”。
- ✓ 修法:组件各自提供 `DoubleClickIsPlainPress(x, y)`(**该落点有没有双击语义**),
宿主 `WM_LBUTTONDBLCLK` 据此决定走 `OnLButtonDown`(当作按下)还是 `OnDoubleClick`:
* `PlotView`:浮动面板(含复选框/滑块/复位/✕/标题栏)、「回到最上方」按钮、绘图区滚动条、GX 徽标 ⇒ 按下;
时间光标线 / 读数标签 / 图例条目名 / 绘图区空白 ⇒ 保留双击;
* `SignalPanel`:样式区数值框、「目标」行名称与 GX 标签、行**名称区域** ⇒ 保留双击;
**其余全部**(含列表之外的样式区按钮/筛选框/空白)⇒ 按下;
* 弹出菜单 / 拾色器打开时,`WM_LBUTTONDBLCLK` 也按各自 `OnLButtonDown` 走。
#### 五、分组行:名称恢复单击展开收起 + 只有**快速双击**进入改名(用户 2026-10-17)
- `OnLButtonDown` 的分组分支不再排除 `RowPart::BODY`:**三角 /「组X」/ 名称本体**统一折叠展开。
- `OnDoubleClick` 的名称区域:**只有快速双击**才进入改名 —— 阈值 `kGroupRenameDoubleClickMs = 300`
(比 Windows 的 `GetDoubleClickTime()`(通常 500ms)**更紧**,用户要求“降低灵敏度”);
超出阈值的“慢双击”只当**又一次单击**(再切一次折叠);快速双击则**先撤销**按下那一次对折叠状态的
切换(`m_groupNameClickWasCollapsed`)再进入改名,保证进编辑时折叠状态与点击前一致。
⚠️ 需要记录「最近一次名称点击的时刻 / 行 / 点击前折叠状态」三个成员。
#### 六、信号重现时网格滚动回到最上方(用户要求)
- ⚠️ 这一整类情形(“有几屏网格 → 没有任何信号显示 → 又出现几屏网格”)必须从最上方开始看。
旧实现保留了上一次的 `Tab().LaneScroll()` ⇒ 重新显示后**停在与取消显示时相同的滚动位置**。
- ✓ `Application::SynchronizeView` 的 `else if (!tab.ViewInitialized())`(= 首次出现可见信号)分支里
在 `FitAll()` **之后** `tab.LaneScroll() = 0.0f`(`FitAll` 本身不动滚动量,见 §11.2 第 112 条)。
#### 七、⚠️ 本轮方法论教训
1. ⚠️⚠️ **绝对不要用 PowerShell `Get-Content -Raw | Set-Content -Encoding UTF8` 改源码**(本轮又踩了一次):
PS 5.1 按 ANSI/GBK 解码 UTF-8 ⇒ 中文注释整份变乱码 + 加 BOM。
✓ **补救**:乱码是**无损可逆**的 —— 读成 UTF-8 字符串再按 `GetEncoding(936).GetBytes()` 写回,
即可还原原始 UTF-8 字节(再删掉 BOM 变成的 `?` 首字节)。✅ 但**正确做法**是:
用**编辑工具**改源码,或用 `[System.IO.File]::ReadAllText/WirteAllText(..., UTF8Encoding($false))`。
2. **动画耗时必须在同一二进制、同一起始状态下对比**:早期用「改代码 + 重编」做 A/B,
结果被两次运行之间**不同的预热状态**污染,得出「底衬兜底 +8ms」这种误导结论;
改成**运行时开关**(环境变量)在**同一次运行**里对比才可靠。
3. **对比参照物非常重要**:把「手动拖滚动条」当作流畅度的基准线,
才能一眼看出动画帧「贵在哪」(整窗重绘 vs 面板固有代价 vs 预热不足)。
---
### 9.44 滚动动画不再受巨型信号影响 + 「N 项」计数的点击语义 + 浮动面板首现位置与开关卡顿(本轮)
> 用户 2026-10-17 第八轮(本机)。四项需求:
> ① 快速点击分组行右侧的「N 项」计数会**进入组名改名** —— 它应只响应展开/收起;
> ② 滚动过渡动画在**巨型信号网格**(`VERY_BIG_DATA`)处卡顿,本不该受"网格内信号是否画好"影响;
> ③ 在下方的网格里开启实验性功能,浮动面板**首次出现在右下角**(应在右上角);
> ④ 面板出现/关闭卡顿(三明治加载时更明显,关面板要等一会才消失)。
> 离屏验证:新增 `.preflight/perf/lanescroll_main.cpp`(`lanescroll.exe`,**23/23**);
> 既有 `verify` 49/49、`panelanim` 158/158、`datacheck` 69/69、`edgecheck` 46/46、
> `errcheck` 19/19、`gridanim` 93/93;`make release` / `make debug` 零警告。
#### 一、滚动动画卡顿的**真正根因**:`BlitWideBuffer` 返回 false ⇒ 退化成「整幅重绘」
⚠️⚠️ 上一轮(§9.43)修的是"动画期间的图层重建"与"定时器失效范围",但**没有**解决主要矛盾。
本轮用**逐阶段计时**(临时插桩,测完已删除)定位到:
- 滚动到**从未构建过图层的区域**时,可见网格一个都贴不出像素 ⇒ `BlitWideBuffer()` 返回 `false`
⇒ 落到 `Paint()` 末尾的**整幅重绘** ⇒ 把**本帧全部可见网格的波形**(含千万级采样的巨型信号)每帧重画一遍。
- 实测(MF4 124 网格 + `VERY_BIG_DATA` 5 网格,点选最后一个信号):
单帧 **20~35ms**,且「可见的巨型信号网格数」越多越慢(0 个 ≈ 20ms、5 个 ≈ 34ms);
对照**手动拖滚动条**只要 ~6ms。
- ✓ 修法:`BlitWideBuffer` 只要还能画**网格底衬**就返回 `true`(`backing` 非空):
```cpp
if (blits.empty() && backing.empty()) { return false; } // 连底衬都没得画才交给整幅重绘
```
这正是用户要求的语义:“没来得及画信号时就按**只有网格叠底**的状态继续滚动”。
**最终实测**(同一工况,逐帧):均值 **23.0ms → 11.2ms**、最坏 **35.6ms → 13.1ms**;
含 5 个巨型信号网格的帧 **33ms → 10.7~12.3ms**(与不含巨型信号的帧一致);
手动拖滚动条参照 4.5ms。
#### 二、`WM_TIMER` 帧的「只重画面板」快路径(额外 −2.7ms/帧)
- 面板滚动动画期间 `m_chromeDirty` 会把**标签栏 / 工具栏 / 整窗底色**一起重绘(实测 +2.7ms)。
- ✓ 新增 `Application::m_panelDirty`:滚动定时器在**只有面板动画在跑**时置位,
`Render` 走 `else if (panelOnly)` 只重绘**面板 + 分隔条**(`SignalPanel::Paint` 会铺满自己的区域);
`m_chromeDirty`(真内容变化)仍走完整装饰重绘。
- ⚠️ **回送**仍是 `if (fullFrame) {整窗} else {绘图区}` 的**无条件分支**(遵守 §9.41 的教训:
绝不让"更新区域"决定画什么/回送什么;这里决定的是"重画笔哪一层",且由**改变像素的那条路径**显式置位)。
- 实测帧耗时拆解:`plot 4.8ms` / `plot+panel 11.4ms` / `plot+全装饰 13.9ms`。
#### 三、分组行「N 项」计数:只折叠展开,**绝不**进入改名(用户要求)
- 新增 `RowPart::GROUP_COUNT` 与 `SignalPanel::GroupStatsRect(row)`(**绘制与命中的唯一几何来源**,
命中矩形在文本左右各外扩 3 逻辑像素)。
- `RowHitPart` 在**名称区域之前**判定计数区 ⇒ 点击计数返回 `GROUP_COUNT`:
* `OnLButtonDown`:折叠/展开(与三角、「组X」、名称本体一致);
* `OnDoubleClick`:**只折叠/展开**,任何点击频率都不进入改名;
* `DoubleClickIsPlainPress` 对它返回 **true** ⇒ 快速连点每一击都生效。
- ⚠️ 「快速双击进改名」的计时只对 `RowPart::BODY`(名称本体)记录 —— 否则“点一次计数、紧接着点一次名称”
会被误判成双击。
#### 四、浮动面板**首次出现**的位置:右上角(用户要求)
- 旧默认位置:`left = 右侧 - 16`、`top = plotTop + 16 + laneIndex*28`。
⚠️ **上百个网格时 `laneIndex` 很大** ⇒ `16 + laneIndex*28` 早就超过绘图区高度 ⇒ 被夹到**右下角**
(实测 plotH=1081 时 `topMargin=964`)。
- ✓ 改为按「**当前真正显示出来的面板**中排在本面板之前的个数」错开(固定遍历顺序:网格下标 → 面板种类),
并把错开量夹到“面板下沿不超过绘图区 45% 高度”以内:
* 第一个面板 ⇒ 错开 0 ⇒ **正好贴在上右角**(实测 `topMargin=24`、`rightMargin=24`);
* 依次下移 28 逻辑像素,6 个面板时最坏 `topMargin=234`(仍在顶部 22%)。
- ⚠️ 判定「面板是否显示」**不能只看 `*Panel.open`** —— `LanePanelState::open` **默认就是 true**,
必须同时看功能是否启用(与 `EdgePanelLayoutFor` 的 `enabled && open` 一致)。
#### 五、开关实验性功能 / 关闭面板卡顿的根因:`RebuildWideBuffer()` 整批重建
- ⚠️⚠️ 四个 `SetLane*`(边沿计数 / 中心轴 / 均值滤波 / 弱峰调整)在末尾都调 `RebuildWideBuffer()`
(注释说是“预建宽缓冲基底”),而它**同步重渲染全部网格的图层**:
129 个网格(含 5 个千万级采样的巨型信号)整批重建要**几百毫秒**
⇒ 点一下功能开关 / 关闭浮动面板就会“卡住一段时间”(用户报告,三明治加载时更明显)。
- ✓ 新增 `PlotView::EnsureLaneLayersPrefetched()`:
* **只有**「图层数组与网格数量不符」(首次绘制 / 增删网格)才同步整批建立;
* 其余只 `RollWideBuffer()` 把缺失 / 过期的网格登记进待重画队列,由既有预算(每帧最多一个)逐帧补齐。
这已足够达成原本目的(拖参数滑块时视界在变 ⇒ 本就不重建、直接用现有基底)。
- 实测(关/开面板,网格内含巨型信号):**几百毫秒 → 22ms 以内**;巨型信号网格上开关均值滤波同样 15ms 量级。
#### 六、⚠️ 方法论教训
1. **“修完感觉没变”时要重新插桩定位,而不是继续猜**:上一轮改的两处(动画期禁重建、定时器失效范围)
合计只省了 ~8ms,真正的 20ms 来自 `BlitWideBuffer` 退化成整幅重绘。
2. **逐阶段计时(临时插桩)比"改代码做 A/B"快得多**:在 `Paint` 里插 5 个 `QueryPerformanceCounter`
就定位到"宽基底分支内部几乎不耗时 ⇒ 一定是走了别的分支"。
3. **`LanePanelState::open` 默认 true** 这类"默认值"极易让统计类判据出错(本轮“已打开面板数”一度算成 8)。
4. **面板首现位置用 `laneIndex` 做错开量**是典型「小数据量下正确、上百网格时崩掉」的写法;
任何与 `laneIndex` 线性相关的屏幕坐标都必须重新审视。
---
### 9.45 多层加载时拖拽卡顿根治 + 高亮命中「线段中段」/ 散点高亮 + 选区放大也要高亮 + 自动点线判定修正与规则收窄(本轮)
> 用户 2026-10-17 第九轮(本机)。四项需求:
> ① 三明治加载(MF4 + `VERY_BIG_DATA` + MF4,全部显示)后点选信号跳转到中间的巨型网格,
> 用左/中键拖拽视界明显卡顿,且之后在任何位置拖拽都卡;
> ② 放大到自动点线后,鼠标只有靠近**数据点**才高亮,**两点之间的连线**不行;散点模式只有拖拽提示没有高亮;
> ③ 「选区放大」模式下完全没有悬停高亮(旧规则是刻意禁掉的);
> ④ 同时加载 MF4 + `VERY_BIG_DATA` 时 MF4 信号在**默认视图**就被误判成稀疏、自动切点线。
> 离屏验证:新增 `.preflight/perf/hoverdrag_main.cpp`(`hoverdrag.exe`,**20/20**);
> 既有 `verify` 49/49、`panelanim` 158/158、`datacheck` 69/69、`edgecheck` 46/46、
> `errcheck` 19/19、`gridanim` 93/93、`lanescroll` 23/23;`make release`/`make debug` 零警告。
#### 一、拖拽卡顿的**三个叠加根因**(用逐阶段计时定位,实测 39ms → 8.8ms/帧)
1. ⚠️⚠️ **每次「横向补条」都重跑 `BuildLaneVisuals()`(O(网格数))**:
`RollWideBuffer` 每帧把每个**可见**网格的图层滚回“居中”位置并补一条 2~8px 宽的竖带,
而 `RenderLaneLayerRegion` 内部有**两次** `BuildLaneVisuals()`(每次都对全部网格算 `ComputeLaneRange`)。
253 个网格 × 30 个可见网格 ⇒ 单帧 `RollWideBuffer` **26.8ms**。
✓ `RenderLaneLayerRegion` 不再调用 `BuildLaneVisuals()`:进入前完整快照 `m_lanes` 与
`m_laneContentHeight`,结束时直接拷回(`m_lanes = savedLanes`)。它只渲染**一个**网格,
其余网格的几何保持原值即可。
2. ⚠️ **重新居中本来就不是必需的**:`BlitWideBuffer` 贴图时按 `(ViewMin - layer.wideMin) * scale`
算源偏移,只要还在位图覆盖范围内,**不滚动也画得对**。因此改为:
* `kLayerRollQuantumX = 32`:位移不足一个量子就不滚;
* `kLayerRollsPerFrame = 3`:每帧最多重居中 3 个网格(分摊到多帧,纯优化可以随便分摊)。
3. ⚠️⚠️ **位移过大时“立刻整块重画”**是巨型信号的灾难:离屏网格被跳过像素跟随,位移会累积;
一旦它进入预取带,旧实现会**同步整块重画**(25M 采样 ⇒ **20~130ms**,实测拖拽最坏帧 28ms)。
✓ `kLayerRollMaxShiftPixels = 160`:超出就**作废图层 + 登记待重画**,本帧由 `BlitWideBuffer`
的**网格底衬**兜底(正是用户要求的“没来得及画信号就按只有网格叠底继续拖”)。
同时 `RollWideBuffer` **只处理可视/预取带内的网格**(离屏网格不做任何像素工作)。
4. 附带:**包络带路径也支持横向补条裁剪** —— 旧实现只对“逐点描边”路径按条带裁剪,
包络带总是构建**整幅可见范围**的包络 ⇒ 巨型信号一条 32px 竖带要 **5ms**。包络是**逐像素列独立**
计算的,因此只构建条带覆盖的列即可(像素与整块渲染一致)。
**实测(`hoverdrag`,MF4 + VERY_BIG_DATA + MF4 = 253 网格,点选后拖拽 40 帧)**:
| 指标 | 修前 | 修后 |
| --- | --- | --- |
| 拖拽平均帧 | **39.2ms** | **8.2ms** |
| 拖拽最坏帧 | **47.9ms** | **10.3ms** |
| `RollWideBuffer` | 26.8ms | 0.03ms |
| 远离巨型网格处拖拽 | 43.0ms | 9.4ms |
#### 二、高亮命中「两点之间的连线」(用户报告:只有数据点附近才高亮)
- ⚠️ 根因在 `HitTestSignal` 的**横向窗口**:它用 `lower_bound(fx-tol)` / `upper_bound(fx+tol)`
取顶点区间,然后只扫 `[windowFirst, windowLast)` 的线段。放大到点线模式后相邻点相距几十像素,
鼠标落在两点**中间**时两个端点都在窗口外 ⇒ `windowFirst >= windowLast` ⇒ 直接跳过。
- ✓ 修法:线段扫描上界改为 `min(stop, windowLast + 1)`(必须包含“一端在窗口内、另一端在窗口外”
的跨窗口线段);散点命中的顶点范围也向两侧各扩一个。
- 实测:11 段长线段的中点,命中 **0/11 → 11/11**。
#### 三、散点模式的高亮 = 数据点高亮环(不含连线)
- `PaintSignalHalo` 原来对 `DrawMode::SCATTER` 直接 `return` ⇒ 散点信号悬停**只有提示、没有高亮**。
- ✓ 现在散点模式给每个**参与绘制**的散点套一圈半透明强调色圆环(步长与绘制同源 `kMaxScatterPoints`);
线段 / 点线仍用原来的折线光晕(用户:“原来的线段和点线模式的高亮去掉数据点之间连线的部分似乎就还不错”)。
- ⚠️ 信号列表 / 图例 / 样式区「目标」行的联动高亮走的是**同一条** `PaintSignalHalo` 路径(`m_externalHoverSignal`),
因此散点信号在列表里悬停时同样会亮起数据点环。
#### 四、「选区放大」模式:保留悬停高亮,但不允许拖信号
- ⚠️ 旧规则(§9.34 之前)在 `OnMouseMove` 里直接 `if (ZoomMode == NONE)` 才做 `HitTestSignal`。
- ✓ 现在**任何缩放模式**都做悬停命中;`OnLButtonDown` 在 `SELECT` 模式下仍然直接进入 `RUBBER_SELECT`
(信号拖拽只在 `NONE` 模式下成立),因此“能高亮、能看提示、但拖不动”。
- ✓ 提示文案在 `SELECT` 模式下**只显示信号名**(去掉“· 按住拖拽可移入其它网格”的尾缀)。
#### 五、自动点线误判:判定范围必须限制在「该信号真正有数据的时间段」
- ⚠️⚠️ **根因**:`MinorCellSparse` 把**整个视界**切成小格。同时加载 `VERY_BIG_DATA`(跨度 ~130s)
与 MF4(数据只覆盖 ~11s)时,MF4 信号在 **90% 的格子里本来就没有任何采样点** ⇒
“存在一个格子内 < 5 个点”恒成立 ⇒ **默认视图就被判成稀疏、自动切点线**。
(用户猜测是“用了缺省绘制的抽稀点”—— 实际不是抽稀,是**空格子**;但现象与猜测一致。)
实测:248 个 MF4/中等信号,**248 → 0** 个被误判。
- ✓ 修法:先用「可见数据的显示时间跨度」`[dataMin, dataMax]` 收窄参与的格子(`firstCell..lastCell`),
鸽巢判定也用**参与格子数**作分母;端点无效(NaN)时做 64 步有界探测并回退到轴区间。
#### 六、自动点线**只对「线段」模式生效**(用户本次要求)
- ✔ `DrawMode::LINE` → 放大时自动切 `LINE_SCATTER`;退出时(用户没手动改过)恢复 `LINE`。
- ✘ `DrawMode::SCATTER`:**不**自动切换(散点已足够提示数据点位置,再叠连线反而干扰)。
- ✘ 已经是 `LINE_SCATTER` 的:不动。
- ⚠️ 用户手动改过样式(`autoScatterUserOverride`)时**不再自动切换**,避免“改完下一帧又被改回去”。
- 实测:LINE → 放大后 `mode=2 auto=1` → 适应视图后 `mode=0 auto=0`;SCATTER → 放大后 `mode=1 auto=0`。
#### 七、⚠️ 方法论教训
1. **绝对毫秒阈值会被机器状态污染**:本轮同一份**未改动的基线提交**在几小时后跑同一探针,
数值从 11ms 变成 26ms(普通整帧从 8ms 变成 21ms)。⇒ 断言必须用**相对基准**
(如“同一时段的普通整帧耗时”或“同一时段的手动拖滚动条耗时”),否则假失败。
2. **printf 的参数个数必须与格式串匹配**:本轮探针漏传参数导致读栈上垃圾,
把 `roll=130.49`(其实是 span 值)当成真实耗时,误导了排查方向。
3. **先分离“是否真的贵”再优化**:滚动只是“把位图重新居中”,而贴图本来就按 `wideMin` 算偏移 ⇒
**不滚动也画得对**。识别出这一点后,量子化 + 每帧限量就都是安全的。
---
## 10. 变更日志(本文件维护记录)
| 日期 | 设备/模型 | 摘要 |
| --- | --- | --- |
| 2026-10-11 | DeepSeek-V4.1-Flash | 新建本文件;记录项目要点与规范;修复「实验性功能」二级菜单瞬间关闭问题。 |
| 2026-10-11 | DeepSeek-V4.1-Flash | 修复右键网格菜单后悬停光晕/提示气泡残留;修复「启用带预览曲线的实验性功能后平移/拖光标 300ms+ 卡顿」(`paintDerivedCapsule` 巨窗每帧重算,收窄请求窗口,显示效果零变化)。 |
| 2026-10-11 | DeepSeek-V4.1-Flash | 深度修复「实验性功能预览曲线导致的交互卡顿」:大窗口改入缓存(静止重绘 2247ms→0.25ms)、交互期间延迟重算(弱波增强拖滑块 2303ms→18ms)、计算缓冲复用;显示逐像素不变。 |
| 2026-10-11 | DeepSeek-V4.1-Flash | 架构升级:**派生曲线计算与 UI 绘制分离**(后台线程 + 双缓冲)。绘制线程永不等计算,弱波增强拖滑块/平移从 2303/2247ms 降到 ~15ms;结构变化前 `PauseDerivedAsync` 保证线程安全;小窗口同步路径与旧实现逐像素 0 差异。 |
| 2026-10-11 | DeepSeek-V4.1-Flash | 完善异步:后台改**多线程池**(预览曲线并行计算、更快出现);`PaintLane` 分两遍把**预览曲线画在全部原信号之上**(不再被覆盖);**快速帧只用于改主体的拖拽**(拖光标/框选改为命中场景缓存,约 1–4ms)。小跨度逐像素 0 差异。 |
| 2026-10-11 | DeepSeek-V4.1-Flash | 预览曲线改为**按整条信号计算**,与「构建为新信号」逐位一致;新增**平移装饰层像素缓存**(图例 + Y 轴刻度在拖拽时直接贴回,2ms/大格拖拽 13ms→约 5ms);后台任务按 (信号,种类) 去重以防拖滑块淹没线程池。 |
| 2026-10-11 | DeepSeek-V4.1-Flash | 探针定位"样式/参数滑块在深放大下极卡":根因是**样式补丁用了抗锯齿**(≈72ms/网格)且**后台曲线发布时整幅作废**导致每帧整幅重绘。修复:样式补丁强制快速帧、拖拽中不整幅作废、交互期省略面板投影 → 透明度/粗细/颜色 77ms→16ms、弱波增强滑块 96–176ms→26ms,静止/结算帧逐像素不变。 |
| 2026-10-11 | DeepSeek-V4.1-Flash | 落地用户提出的**复用像素**方案:新增**平移宽缓冲区**(以视界为中心左右各多渲染半个视界宽),平移时前台只做一次 **BitBlt 截取子矩形**(O(1)),移出边界才重建 → 深放大下平移 **34ms→1.3ms**;静止/结算帧逐像素 0 差异。 |
| 2026-10-11 | DeepSeek-V4.1-Flash | 把宽缓冲推广为**基底复用**:抽出 `PaintLaneDerivedOverlays`、宽缓冲只存基底(不含派生曲线),基底键排除派生参数 → 拖弱波增强滑块 **55ms→5.2ms**;平移 1.3ms、样式 8ms 不变;静止帧逐像素 0 差异。残留:宽缓冲重建在前台(约每屏一次)、非拖拽视界变化仍整幅重绘。 |
| 2026-10-11 | DeepSeek-V4.1-Flash | 宽缓冲改为**滚动式**(自贴图平移 + 只补画新暴露细条),消除"移出边界整幅重建"的周期性尖峰;缓冲只覆盖绘图区、时间轴前台绘制、亚像素误差用锚点限制在 ≤0.5px → 持续拖动平移约 6ms/帧、无尖峰(长距最大 10.5ms);静止帧逐像素 0 差异。残留:每次开始拖拽的首帧需新建缓冲(一次启动尖峰)。 |
| 2026-10-11 | DeepSeek-V4.1-Flash | 消除"开始拖拽"的首帧尖峰:平移首帧用**静态场景缓存初始化宽基底**(`InitWideBufferFromScene`,贴图 + 细条,不再从零重画 2×视界);启用带预览的实验性功能时**预建基底**。实测:平移拖动最大 8.5ms、弱波增强滑块最大 10.8ms(原分别 35/37.6ms),用户全部鼠标交互在该放大区间**全程 ≥60fps、无尖峰**;静止帧逐像素 0 差异。残留:非拖拽视界变化(键盘/工具栏)仍整幅重绘。 |
| 2026-10-11 | DeepSeek-V4.1-Flash | 消除 9.13 残留:**非拖拽视界变化(键盘/工具栏)也走宽基底快路径**(124.6ms→**8.6ms**),并用 `wideKey` 相等判定"视界已停止变化"触发一次**结算**整幅全质量帧以恢复像素精确;分支顺序固定为"静态贴图 → 宽基底 → 样式补丁 → 整幅",宽基底强制全质量抗锯齿且竖向刻度用前台跨度。新增像素对比模式验证:底座管线忠实(meanAbs 0.68/255),平移期仅 ≤0.5px 亚像素差;7 张静态/结算帧与 HEAD **字节一致**;平移中途帧不再有图例/刻度重影。 |
| 2026-10-12 | DeepSeek-V4.1-Flash | 落地用户提出的「各网格独立双缓冲区 + 后台线程绘制」架构:把全局宽缓冲区重构为**每网格信号图层**(宽×3,只含基底不含装饰)、`m_layerFront/m_layerBack` **双缓冲** + `m_layerWorker` **后台线程**(前台只做 BitBlt 坐标偏移,不再绘制波形);前台"图层就绪即用现成像素、内容稍旧也照用,交后台补画后交换";加不收敛保护、前台优先让路、固定加锁顺序(render→layer)、渲染状态完整还原(含 `m_laneContentHeight`/`LaneScroll`)。新增 `BackgroundLayerRenderCount()` 公开计数用于验证后台绘制确实发生(一轮诊断 18~45 次)。实测深放大区间(2.045ms 一大格)各交互 0.7~8.7ms/帧,像素差 ≤0.017(除亚像素平移),静态/结算帧像素精确。 |
| 2026-10-12 | DeepSeek-V4.1-Flash | 修复用户报告的「缩放后平移出现拖影/残留/错误元素」与「2ms 一大格区间仍然卡顿」。根因:①网格纵向滚动量未参与任何内容指纹→滚动时把旧位置像素贴回且每两帧触发一次整幅重绘(≈8fps);②`FindStyleOnlyLanes` 未校验视界→视界变化后仍走局部补丁;③宽基底用含装饰的整幅画面初始化→装饰随平移重影;④装饰缓存的矩形列表与坐标原点两帧不同源→装饰消失/黑块/错位。修复后交互帧 9~19ms → **4.5~6ms**,纵向拖动残留 2 万+ 通道 → 72 通道(亚像素量化),静态/结算帧像素精确。新增 `perf.exe ... 6` 诊断模式(逐帧计时 + 逐像素对比 + 差异包围盒)。未实现:每网格双缓冲后台渲染(需先把 `PaintLane` 改造成无状态渲染器,已在文档 9.15 说明)。 |
| 2026-10-12 | DeepSeek-V4.1-Flash(**起源设备**) | **跨设备记忆合流**:把起源设备(本机)的详细工程记忆**并入本文件**(同一记忆文件,不再另建 `agent_mem_origin.md` 等旁支文件),新增 §11(跨设备协作须知)与 §12(起源设备详细记忆原文)。远端 `gitea/main` 已快进合并到本机(`7608e85` → `f97c4cf`,**16** 个提交)。本机 MinGW-w64 GCC 15.2.0 (UCRT) 下 `make release` 通过。 |
| 2026-10-12 | DeepSeek-V4.1-Flash(**起源设备**) | **修复「缩放后平移出现严重绘制错误/波形抖动/前台被拖慢」**:根因是后台图层线程会改写共享渲染状态(保存→改写→恢复`m_area`/`m_plotRect`/视界/网格矩形),与 UI 线程无锁的平移/命中测试相冲 → 视界被弹回、网格错位;叠加"全局锚点 + 逐网格内容"导致各网格时间错位。修法:**移除后台图层线程**(改为 UI 线程同步维护)、**逐网格独立锚点与滚动**(补细条用该图层自己的 `wideMin`)、跨度变化时同步 `RebuildWideBuffer()`。同时把「弱波增强」改名「**弱峰增强**」并改为 **0~200% 分段插值**(0% 完全不动 / 100% 到较温和一侧 / 200% 完全拉平;新 1.5 = 旧 0.5)。离屏验证:居中图层 vs 整幅重绘 **5 网格逐像素 0 差异**、平移帧对齐 ≤1px、24 次竞态检测 0 覆盖;测试套件 **380/380 通过**。 |
| 2026-10-12 | DeepSeek-V4.1-Flash(**起源设备**) | **修复"按住键拖拽平移时波形质量下降 / 断线 / 周期性卡顿"与"浮动面板互相覆盖时交互错位"**,并把弱峰增强改为**逐信号参数**。① 平移:`RollWideBuffer` 不再整块重画(只平移+补细条,内容过期/锚点过远只入队),新增 `RenderPendingLaneRebuilds(budget)`(**每帧最多重画 1 个网格**,其余下帧)+ `BlitWideBuffer` **局部贴图**(图层没覆盖的部分留空,不再整块跳过);"结算"整幅精确帧**仅限非拖拽且全部图层就绪**(消除拖拽中每两帧的整幅重绘/质量振荡)。实测 `VERY_BIG_DATA` 5 网格深放大平移:中位 **6.2ms**、max 7.6ms(无尖峰);按住键且视界停在边界时 **0.29ms/帧**。② 弱峰增强:`WeakPeakItem` 改为携带 `levelPercent(0–200)+iterations`,面板改为与均值滤波同构的**逐信号两个输入框**(删除网格级两滑块),哈希/快照/构建/预览全部改为逐信号。③ 浮动面板:新增 `m_panelOrder` 层序,**命中从最上层往下**、点击 `BringPanelToFront`、新面板置顶;修复"上层 ✕ / 标题栏被下层参数框抢走"。离屏测试 **388/388 通过**。 |
| 2026-10-12 | DeepSeek-V4.1-Flash(**起源设备**) | 弱峰增强的参数输入改回**逐信号滑块**(每信号两条轨道:增强等级 0–200% / 迭代次数 N),并让「迭代次数 N」**上限可就地编辑**:点击滑块右侧数值 → 整行变成输入框(`EditFilter::BYTE` 只收数字、空内容显示灰提示当前上限、`0`/超限/空 保持原值、下调后当前迭代次数自动被夹到新上限);上限进 `PanelSlotKey` 但不进 `SceneContentHash`(不影响波形重画)。离屏测试 **409/409 通过**。 |
| 2026-10-12 | DeepSeek-V4.1-Flash(**起源设备**) | **修复"斜向/不规则拖拽自由移动视界时的卡顿、掉帧与波形错误绘制"、"预览曲线不如普通信号细腻(无抗锯齿)"、"拖动参数滑块时预览曲线跳帧闪烁"**。① 根因:拖拽平移的**纵向分量**会逐帧小改被拖网格的 `yMin/yMax`,而**网格幅值范围同时参与「宽基底可用性」几何指纹与每个网格的图层键**,于是每帧都退化成整幅重绘(`m_fastFrame` 关抗锯齿)+ 5 个网格全部入队(一帧只补 1 个)→ 质量下降、卡顿、图层相互不一致(断线)。修法:`SceneContentHash` 新增 `includeLaneRange`(`SceneGeometryHash()` = `false,true,false`,幅值不进"能不能走快路径"的判定),幅值改由**逐网格** `LaneLayerHash(laneIndex)`(`LaneLayerKey` 的基)承担 → 只让真正变化的那个网格失效;并把 `RenderPendingLaneRebuilds(1)` **移到 `BlitWideBuffer` 之前**(本帧补好的网格本帧就可见)。② 预览曲线抗锯齿不再因交互(`m_fastFrame`)关闭(仅保留"陡峭段过多"这一条形同无声的逃生口)。③ `AcquireNewestCurve` 改为**只按「信号 + 曲线种类」匹配**(参数无关,优先挑覆盖所需区间的条目)→ 拖参数滑块时用上一帧的曲线顶替,后台算好再无缝替换,**不再闪烁/消失**。离屏验证:斜向拖拽 40/40 帧走宽基底快路径(中位 9.2ms,无尖峰),参数连改 10 次 **0 个空白帧**,抗锯齿回归断言(快帧混淆边缘像素 8149/8404);`uitest` **420/420 通过**、`panperf` PASS、`layercheck` 保持"0 次视界覆盖 / 平移对齐 ≤1px"。 |
| 2026-10-12 | DeepSeek-V4.1-Flash(**起源设备**) | **修复"深放大下左右平移流畅、上下/斜向平移卡顿"(用户猜测:上下没有预渲染机制——已证实)与"深放大时拖拽光标跑飞"**。① 量化复现:`panperf` 新增纯竖直拖拽 + 逐帧统计**图层重画次数**,并在 5 网格全开 4 类悬浮面板(20 个)后对比 —— 竖直拖拽中位 **166.6ms/帧**、水平 **37.7ms/帧**(4.4 倍差距,用户报告的"上下卡顿"正是它)。② 主因是**面板像素图层被幅值范围击穿**:`PanelGlobalKey()` 计入 `visual.yMin/yMax`,平移的纵向分量每帧小改幅值 → 整层 20 个面板每帧重画(去掉后 166.6→**24.8ms**);次因是**图层没有纵向预渲染余量**(幅值一变就整块重画,实测 40 帧里 40 次 → 修复后 **1 次**)。③ 修法:`LaneLayer` 增加 `marginY/anchorYMax/pxPerValue`,幅值范围按**像素行**精确扩展(上下各半个网格高)、贴图时按 `(anchorYMax - 当前 yMax) * pxPerValue` 取纵向偏移,图层指纹只计幅值**跨度**不计绝对范围;幅值刻度改以**前台可见网格**的跨度/高度取 `NiceStep`(保证与整幅渲染逐像素同源);网格分界线移出图层(改由宽基底路径的 `PaintLaneDividers()` 补画,否则纵向偏移会把它带走)。④ 光标:深放大下平移会把时间光标甩到视界外很远(像"跑飞/丢失")—— 新增 `ClampCursorsAfterPan()`,在**平移**(中键/左键拖拽、Shift+滚轮、方向键)之后夹取;**离散**的视图变化(选区放大/适应视图/按网格时间轴展开)仍然不移动光标;抓取被钉在边缘的光标读数时不再沿用抓取偏移(否则首帧会跳到视界外再被夹回边缘)。验证:`uitest` **432/432**(新增 `[AI]`:贴边读数抓取后光标落在鼠标处误差 0.0px、中键平移 1.5 屏 0 帧出界且跟手)、`panperf` 竖直 3.8~5.0ms(40 帧仅 1 次图层重画)、水平 6.5ms 持平、`layercheck` 0 视界覆盖、新增探针 `.preflight/cursorcheck` 30/30。 |
| 2026-10-12 | DeepSeek-V4.1-Flash(**起源设备**) | **修复"纵向预渲染带出的绘制异常/抽搐"(中键上下拖动、尤其拖到网格之外时),并把「弱峰增强」升级为「弱峰调整」(幅度 -200% – +200%,负值削弱)**。① 绘制异常根因:上一轮给图层加了纵向余量,但"偏移超出余量"仍走**排队整块重画**(一帧只补 1 个网格)→ 重画完成前 `BlitWideBuffer` 只能贴出部分行、**其余留空**(网格内出现空白条带),且重画与贴图的锚点不同源 = "波形没按预期移动";鼠标拖到网格外很远时每帧都触发 → 抽搐。修法:与横向**同构的纵向滚动**(BitBlt 整块滚动 + 只补画暴露的一整条,`anchorYMax -= roll/pxPerValue`),并且**必须先于横向补条**;滚动量**量化**(`kLayerRollQuantum = 16` 像素,不足量子交给贴图 `srcY` 偏移)以保持画布映射稳定、让折线缓存命中(未量化时每帧滚 1~30 行 → 折线每帧重算)。验证:`panperf` 拖出网格 900px 时被拖网格的**空白行数与整幅重绘逐帧一致**(64 vs 64,即无空白条带),竖直拖拽 16 帧仅 2 次图层重画。② 弱峰调整:`ComputeWeakPeakAdjust`(原 `ComputeWeakPeakBoost`)+ `WeakPeakItem.amountPercent`(-200–200,默认 150),面板「增强等级 %」→「调整幅度 %」、滑块量程 -200%–+200%(**0 在正中间**)、数值列加宽以容纳 "-200%";识别规则与旧实现完全一致,正幅度段与旧实现**逐位相同**,负幅度段改为以相邻**异类**极值(波峰看相邻波谷)为参照:-100% 拉到**较近**的那个、-200% 拉到较远的那个(压平/抹除,左右平滑过渡),且**不得反向增强**。离屏验证:`uitest` **443/443**(正幅度 0/50/100/150/200% → 550/555/560/580/600 逐位保持;负幅度 -50/-100/-200% → 峰 500/450/400、谷 500/550/560;超范围夹取;健康峰不动)、`panperf`/`layercheck`/`cursorcheck` 全过。 |
| 2026-10-12 | DeepSeek-V4.1-Flash(**起源设备**) | **修复"上下拖动之后,左右/上下/斜向拖动都出现绘制错误"(真因:图层条带渲染时 GDI 网格线没被限制在条带内),并调整「弱峰调整」的弱峰/弱谷定义、默认幅度与数值符号显示**。① 根因:`PaintLane` 的 GDI 网格线裁剪矩形只限制了横向(`RectF(m_area.X, clipTop, m_area.Width, …)`),而**纵向补条是全宽 × 窄 y** —— GDI 绘制不受 GDI+ 裁剪约束,于是横向网格线被画到条带之外的已正确像素上、**覆盖在波形之上**,并**永久烘进图层**,随后每次平移都带着它一起漂移(用户看到"到处都有绘制错误")。修法:`laneClip = IntersectRect(m_area, 网格可见矩形)` 同时用于 `SetClip` 与 GDI 裁剪。② 新增离屏探针 `.preflight/dragcheck`(真文件 + 真中键拖拽消息:先上拖半个网格高,再左右/上下/斜向、最后拖出窗口;每帧把图层画布的有效条带与"同一 `wideMin` 的新渲染画布"逐像素比较):修复前被拖网格整行不一致(2410px ≈ 2 行全宽),修复后 ≤171px(仅补条边界的抗锯齿边缘)。③ 弱峰调整:**「是否偏弱」改为**"波峰必须同时低于左右两侧各自的(相邻波峰+相邻波谷)均值;波谷反之";默认幅度 **0%**(不改动);滑块右侧数值**始终带符号**(`+150%` / `-150%` / `0%`,验证 +150% 与 -150% 墨迹宽度一致且都比 `0%` 宽 ≥8px)。验证:`uitest` **441/441**、`dragcheck`/`panperf`/`layercheck`/`cursorcheck` 全过、`make clean && make all` 零警告。 |
| 2026-10-12 | DeepSeek-V4.1-Flash(**起源设备**) | **「弱峰调整」升级为三组识别规则 + 滑动条 ±400% 梯度驱动**:第一类(两侧均值都要满足,原规则)在滑动条 0–200% 内走完自身 0–200%;第二类("或":任一侧均值满足即可)从 100% 起参与,own 幅度 = clamp(|a|-100%,0,200%);第三类(只与左右同类极值比较,最宽松)从 200% 起参与,own 幅度 = clamp(|a|-200%,0,200%);负值完全对称。每组自身的 ±200% 映射统一为:+100% → 左右同类极值中较温和的一侧、**+200% → 左右同类极值的均值**(不再取较大的那个);-100% → 相邻异类极值中较近的一个、**-200% → 相邻异类极值的均值**(不再取最小的那个)。同时满足多组时取**幅度绝对值最大**的组。验证:`uitest` **463/463**(第一类点 0/50/100/150/200% → 520/570/620/**625/630**;只满足第二类的峰 130 → 100% 不动、150%/200%/250%/300%/400% = 140/150/162.5/175/175;只满足第三类的峰 195 → 200% 不动、250%/300%/400% = 197.5/200/200;±400% 夹取;滑块中间 = 0%、两端 = ±400%)、`panperf`/`layercheck`/`cursorcheck`/`dragcheck` 全过、零警告。 |
| 2026-10-12 | DeepSeek-V4.1-Flash(**起源设备**) | **修复"按住中键把视界纵向移动好几个网格高(或横向拖很远)后,出现大量绘制错误与持续抽搐"**:根因是 `RollWideBuffer` 对"位移超出余量"只做**排队等后续帧重画**(`QueueLaneRebuild` + `continue`)—— 该帧 `BlitWideBuffer` 取不到源行 → 网格里出现**空白条带**;且每帧都重新入队而 `RenderPendingLaneRebuilds(1)` 一帧只补 1 个网格 → **补不完 = 持续抽搐**;纵向余量只有 ±半个网格高,正常速度的纵向拖拽每帧就超过它,故几乎必现。修法:**只有"内容真的变了"才排队**,距离类问题一律本帧就地解决 —— 位移小于画布则滚动(O(1) BitBlt + 只补暴露细条),超出画布则**立刻整块重画并重新居中**(0.4ms/网格)。验证:新增探针 `.preflight/dragcheck` 按用户要求**全程按住中键不放**地做检查(图层条带逐像素 / 画布覆盖率 / 画面空白行数与墨迹占比),并把纵向滚动临时关掉做 A/B:立刻报 `layer band diff 76221 / drawing errors 11`,恢复后 **0 错误**;`uitest` **463/463**、`panperf`/`layercheck`/`cursorcheck` 全过、`make clean && make all` 零警告。 |
| 2026-10-12 | DeepSeek-V4.1-Flash(**另一台设备**) | **修复「垂直中键拖拽后再平移,网格内容纵向跳变 / 绘制错误」**(详见 §9.16):根因是 `RenderLaneLayerRegion` 结束时**无条件**把 `layer.anchorYMax` 重置为"当前 `yMax`"—— 对整块重画 / 纵向补条成立,但**横向补细条(只改一条竖带、其余像素仍在原锚点)**不成立。纵向拖动以 `kLayerRollQuantum=16px` 量化,停下时图层会残留 `<16px` 的纵向偏移且 `anchorYMax != 当前 yMax`;第一次水平微移就把该偏移**静默清零**(`vOffset→0`)而像素未动 → 整个网格波形纵向错位(最多 15px)。修法:`RenderLaneLayerRegion` 新增形参 `anchorYMax`,内部以它作幅值映射基准(`yMin = anchorYMax - laneSpan`)渲染并写回 `layer.anchorYMax`;横向补条传 `layer.anchorYMax`(保持不变),整块重画/纵向补条传当前(或滚动后)`yMax`。`vOffset==0` 时新旧逻辑完全等价。验证:新增离屏探针 `.preflight/perf/pancheck`(窗口 `SW_HIDE` + 注入消息 + 读图层内部 `anchorYMax`/`vOffset` + 持按钮帧 vs 释放后整幅帧的纵向互相关):修复前 A 场景 `dy=+15px`(lane0 差异 39245),修复后 **`dy=0`**(差异 1220,仅已知列相位残差);对照/中间网格/长距水平/斜向/松开重按共 7 个场景在缩放 0/8/26 步下全部 `dy=0`(中间网格 `+1px` 为整像素量化残差);`make clean && make all` 零警告。 |
| 2026-10-13 | DeepSeek-V4.1-Flash(**另一台设备**) | **交互流畅性三项修复**(详见 §9.17):① **光标不再跟随视界**——删除 `ClampCursorsAfterPan()` 及其在平移路径(中键/左键拖拽、Shift+滚轮、方向键)的三处调用,视图变化时光标时间位置**完全不变**(可滑出视口),`ClampCursorToView()` 只在拖拽光标时使用(mode 14:平移 20 帧后 `CursorTime` 不变且 `offViewport=1`)。② **缩放首帧不再卡顿**——根因是跨度变化时 `Paint` 会**同步重建全部 5 个网格图层**(实测 70~80ms)+ 随后整幅结算(40ms)=单次缩放 ~110ms 卡顿;修法:`BlitWideBuffer` 对跨度过期的图层改用**旧像素 `StretchBlt` 拉伸占位**(不再跳过导致空白)、`Paint` 只在"图层数量不符"时同步重建(新增 `LayersAllocated()`)、`RenderPendingLaneRebuilds` 允许按新跨度重建、且**只在视界稳定后**才花预算精细重绘(`viewStopped` 门控);实测首帧 **70~80ms → ~10ms**。保留"结算"帧(实测宽基底路径与整幅重绘有 17 万+ 像素列相位差,非同源)。③ **超大信号拖预览滑块**——量化发现开销**不在绘制**(`PaintLaneDerivedOverlays` 仅 0.375ms),而在"参数每帧变化"触发的**整条信号**(2576 万采样)预览重算调度;新增 `kHugeSignalPreviewSamples=4000000`,超大信号拖拽期间不再每帧排队重算(15.0→13.7ms,**剩余 ~8ms 根因未完全定位**)。验证:`pancheck` 7 场景全 `dy=0`、`zoomcheck` mode 0/13/14、`make clean && make all` 零警告。 |
| 2026-10-13 | DeepSeek-V4.1-Flash(**另一台设备**) | **深放大中键拖拽卡顿(根因=GDI+ 抗锯齿"满幅锯齿"病态)+ 横向补条裁剪 + 并行网格图层(B/C,详见 §9.18)**。① **B 拆解**:该视界(`[10.2719,10.2831]s`)下 `RollWideBuffer` 补条重绘 **13.08ms**,其余 ≤0.8ms —— 瓶颈全在补条重绘。② **补条裁剪**:横向补条只覆盖窄竖带,却按整条宽窗口构建/描边整段折线;改为按条带 x 范围裁剪采样区间(旁路折线缓存),`RollWideBuffer` **13.08ms → 1.73ms**,拖拽整帧 13~24ms → ~3ms,且**逐像素 0 差异**。③ **病态根因**:拖拽超出预渲染余量时走整块重画,**整宽单网格渲染 655ms** —— 同 4481 点 `DrawLines` 实测 **AA 694ms / 无 AA 29ms(23 倍)**(编码器方波在 1~2 采样/像素形成每列整幅跳变的锯齿,原实现此时既不走包络也不关 AA);修法:**包络带阈值从 2 采样/像素降到 1**,并扩展"梳状(关 AA)"判据(多数列列内跨幅 >0.5×网格高 / 相邻列整幅跳变)——**几何一字未改**;整宽渲染 **655→47.7ms**、补条 6.9→2.4ms、拖拽 200 帧 **avg 4.5 / max 5.8ms / 0 帧 >8ms**。④ **C(用户解除多线程限制)**:实现 `RebuildLaneLayersParallel` —— 每线程一个**隔离 `PlotView`**(自有 `m_workerTab`/`m_area`/`m_plotRect`/`m_lanes`)渲染不同网格,主线程 join 后按真实下标重算 key;并行 vs 顺序**逐像素 0 差异**;⚠️ 踩坑:`BuildLaneVisuals` 在 `m_tab==nullptr` 时清空 `m_lanes`,故**必须给 worker 一个非空的自有 tab**。**诚实结论:并行仅 1.03x**(同机 4 线程 CPU 真并行 3.98x → GDI+ 光栅化被串行化),故**并行对光栅化无收益**,仍保持"视界停后每帧补 1 个网格"。验证:`dragperf` mode 0/1/3/5/6/8/9、`pancheck` 全 `dy=0`、`zoomcheck` 无回归、`make clean && make all` 零警告。 |
| 2026-10-14 | DeepSeek-V4.1-Flash | **装饰层改为带 alpha 的独立图层(消除 GX/图例外围"直角白边")+ 图例圆角托底与悬停/选中配色统一 + 预览曲线内存释放(详见 §9.20)**。① **白边根因**:装饰层原为"从后备缓冲区抓取一块**不透明矩形**",圆角之外(属于波形)的像素被一起烘进缓存,为让其跨视界可复用又在圆角外铺了直角不透明底色 ⇒ 密集波形上就是一圈直角白边。**修法**:`m_decorDc` 改 **32bpp DIBSection**、新增 `RenderDecorCache()` 把装饰**单独绘制**进全透明位图(不再抓取)、`BlitDecorCache()` 改用 **`AlphaBlend`**(`AC_SRC_ALPHA`,`GetProcAddress(gdi32!GdiAlphaBlend)` 解析、不新增链接依赖)⇒ 圆角外透出**当前**波形;并做 **alpha 修补**(GDI 文本会把写入像素的 alpha 清 0,不修补文字会变透明洞);删除全部外扩直角底色。② **图例**:逐条目圆角托底(与 GX 徽标同风格),**选中态 = 悬停态配色**(`accentSoft`+`accent`+`textStrong`),悬停在不透明托底上重绘色块/文字并保留曲线光晕;`LegendHit` 新增 `radius`;补齐「+N」/「(无可见信号)」提示的装饰矩形。③ **内存**:`thread_local` 工作缓冲**保留容量封顶**(`kReuseBufferMaxElements` + `ReuseBufferScope`,超大计算用完即释放)+ 关闭最后一个预览功能时 `TrimDerivedCurveDataIfIdle()`(`PauseDerivedAsync` 让线程退出、thread_local 析构归还内存)—— 实测 1792MB → **关闭后 1203MB**(修复前不下降)。④ 性能:`BlitDecorCache` **0.19ms/帧**、`RenderDecorCache` 2.4ms(仅内容变化时);`zoomcheck` mode 13 收敛帧与整幅重绘**逐像素 0 差异**(此前 3503)。验证:离屏 `verify.exe` **49/49 通过**、`make release` 零警告。 |
| 2026-10-14 | DeepSeek-V4.1-Flash | **修复远端「调整」功能引入的性能回归 + 根治首帧卡顿 + 图例交互/托底重做(详见 §9.19)**。① **8 倍回归**:远端把 `DataSet::Style(ref)` 写进了 `AutoRangeLaneToView` 的逐采样循环(亿级解析调用)→ 工具栏「Y 轴自适应」在全视图下 **1200ms**(旧提交 147ms);修法:样式指针提循环外 + **整段自适应取已缓存 min/max(O(1),结果逐位一致)** + 无变换走紧凑循环 → **0.02ms**。② **首帧卡顿根治**:加载后宽基底图层从未分配,用户第一次平移/缩放时 `Paint` 在那一帧**同步重建全部 5 个网格图层**(首帧 **64.3 → 13.0ms**);修法:新增 `WarmLaneLayersStep()`,在**命中静态场景缓存**(视界未变、非拖拽)时用预算机制**每帧至多补 1 个网格**,把整批重建摊到静止帧。③ **派生信号把「调整」烘进数据**(不再继承样式指纹);⚠️ 当时写的“仅 `offsetTime` 因共用文件时间轴仍需继承”**已被 §9.21 推翻**(现在 `offsetTime` 也按 `time[i]-offsetTime` 重采样烘进采样值)。④ **GX 拖拽让位判据改为鼠标指针**(鼠标越过相邻网格中心水平线即让位),并给 GX 徽标加悬停高亮(叠加层 + `IDC_HAND`,与信号列表 GX 徽标一致)。⑤ **图例命中矩形在装饰缓存路径下必须重建**(原先命中缓存时提前 return → `m_legendHits` 被清空且不重建 → 中键平移后图例悬停/点选全失效)。⑥ **每个图例条目加圆角托底 + 装饰矩形改为逐块 + 托底不透明铺满外接矩形** → 消除「GX/图例周边旧波形像素残留」(逐像素验证装饰矩形内 **0 差异**);悬停高亮为不透明托底并**重绘色块与文字**(否则内容被盖住)。验证:离屏 Application harness **40/40 通过**,`zoomcheck` mode 20–26 量化,`make release` 零警告。 |
| 2026-10-14 | DeepSeek-V4.1-Flash(**起源设备**) | **派生曲线内存治理 + 边沿统计统一预算状态机(含中心轴)(详见 §9.23)**。① **弱峰调整内存**:`PeakExtremum` 字段重排 24→**16 字节**(密集极值信号省 ≈1/3 极值表);`ComputeDerivedWindow` 整段窗口**不再复制采样**(省 8 B/采样 ≈ 206 MB/条);**大任务并发上限**(内存充足 2 / 紧张 1,实测 4 并发 3M 采样 412→324 MiB);**内存严重不足时直接拒绝计算并只告警一次**(`kMessagePlotLowMemory`,不再 `bad_alloc` 崩溃);**低内存节流**(阶间 `Sleep(1)`、并发 1,"少量多次");**取消标志贯通到算法**(每阶 / 每 1M 采样检查)⇒ **过时任务立即退出并归还缓冲**;`AvailablePhysicalBytes()` 按 200ms 缓存。② **边沿统计**:统一为 `EdgeScanState` + `EdgeScanFor`(普通/中心轴共用),阈值/参数变化就地把状态重置、重置当 tick 不展示部分值(修掉"错误的且不更新的数字");**未完成时主动 `PostMessage(kMessagePlotDerivedReady)` 请求下一帧**(修掉"鼠标不动就一直统计中、移上去才好");中心轴所需曲线**只在后台算**(原先 `CurveSpanFor` 未传 `allowAsyncCompute` ⇒ 同步算整条均值滤波曲线 ⇒ 界面停顿数秒),`MeanFilterValueAt` 同步整条计算的路径也已移除;⚠️ `ScheduleCurveJob` 改为**幂等**(否则统计侧每帧请求会造成"请求→作废→重请求"死循环)。实测:6M 采样首帧 1.4ms、拖动中 10 次查询 1.07ms、**同帧 5 条信号合计 1.3ms**、保持不动收敛到与参考实现逐位一致。③ **离屏 harness/探针**:`main` 开头设置 `BELOW_NORMAL_PRIORITY_CLASS` + 线程低于正常(再长的测试也不抢用户前台 CPU),保持隐藏窗口 / 只注入合成消息 / 注入后立即 `ReleaseCapture`。验证:`uitest` **751/751 通过**(连跑 3 次稳定),`make release` 零警告。 |
| 2026-10-14 | DeepSeek-V4.1-Flash(**起源设备**) | **边沿统计改为“定向失效”(详见 §9.24)**:改变**一个**信号的中心轴参数时,过去用 `m_edgeScans.clear()` 把**两个浮动面板、所有信号**的累积状态全部丢掉 → 所有统计从头重算。新增 `InvalidateEdgeScansFor(const SignalData*, bool centreOnly)`,只擦除该信号(中心轴参数变化时只限它的中心轴状态);均值滤波参数变化与统计无关(不作废);勾选切换只改变“是否显示”(三个 `clear()` 全部删除,不再作废任何缓存)。验证:`uitest` 新增 `[AT]`——3 信号合并同一网格,用**真实编辑路径**改第 2 个信号的中心轴参数,断言被改信号中心轴已作废、另两个信号 `scannedPair/rising` 逐位不变、被改信号的**普通**边沿计数逐位不变、未改动信号仍 `complete` 瞬时返回;**774/774 通过**(连跑 3 次稳定),`make release` 零警告。 |
| 2026-10-14 | DeepSeek-V4.1-Flash(**起源设备**) | **修复“偶发的改一个参数、其它信号也重新统计”(详见 §9.25)**:根因是中心轴统计靠 `AcquireCoveringCurve`/`ScheduleCurveJob` 拿**整条**曲线 — 千万级信号一条 ≈103MB,多试几个参数值就把曲线缓存推到 768MB 上限 → `PruneCurveEntriesLocked` 从队首淘汰(往往是**其它信号**的曲线)→ 那些信号显示“统计中…”并在后台重新排队(**偶发**,取决于缓存里积了多少条目)。修法:`EdgeScanState` 新增 `localCurve/localBegin/localEnd/localValid`,扫描时**就地计算「当前扫描块 ± 滤波半径」**(半径 = `points*iterations`,窗口 ≥ 8 个块,成本计入扫描预算,扫完即释放)⇒ 与曲线缓存、与其它信号**彻底解耦**(实测统计时 `curve entries 0`);删除“曲线未就绪就把整 tick 预算清零”的门控(它会把同帧后面排队的其它统计**饿死**);`kEdgeScanStateLimit` **16→64** 且超限时优先淘汰「已扫完」的状态。验证:新增间歇性探针 `[AU]`(5 信号同网格、真实编辑路径连改 25 次、逐帧分类 CASE A/B/C)——修复前 baseline **0/10 完成**,修复后 **10/10 完成且 25 次编辑 CASE A/B/C 全 0**;新增「本地窗口 vs 整条曲线」12 组参数(points 5/17/64/300 × iterations 1/2/4)**逐位一致**断言;合计 **789/789 通过**,`make release` 零警告。 |
| 2026-10-14 | DeepSeek-V4.1-Flash(**起源设备**) | **新标签页分组默认折起 + GX 徽标悬停不改光标 + 标题栏 RAM 改用「私有工作集」+ 边沿计数改为「逐块累积 + 每 tick 预算推进」(详见 §9.22)**。① `NewTab(bool collapseGroups)`(首个标签页 false、用户新建 true)+ `TabConfig::SetGroupsCollapsedByDefault/SetAllCollapsed`;`SyncToDataSet` 用该默认值初始化**新出现的**分组。② `QueryCursor` 的 GX 徽标分支由 `IDC_HAND` 改为 `nullptr`(悬停不改形状,仅拖拽时 `IDC_SIZENS`)。③ **RAM 口径修正**:原取 `WorkingSetSize`(总工作集,含映射 DLL/共享页与已释放未回收的驻留页)→ 改为 `PROCESS_MEMORY_COUNTERS_EX2::PrivateWorkingSetSize`(与任务管理器「内存」列一致;失败退回 `PrivateUsage` → `WorkingSetSize`)。实测加载后 117.55/109.96 MiB、关闭分组后 15.44/7.62 MiB —— **无泄漏,内存完整归还**。④ **边沿计数性能**:改前 25.8M 采样 ≈**115ms/条/帧**(5 条同网格 = 575ms/帧,用户报告"开启即卡顿、之后拖不动");改为按 `kEdgeBlockSamples=8192` 分块累积、每 tick 预算 `kEdgeScanPairsPerTick=1.2M`、区间用块和相加(区间不进键)、拖动横向光标期间不推进并显示上一个完整值 + `complete=false`(`EdgeCountsText` 显示"统计中…/…");实测首帧 1.7ms、单次最坏 1.9ms、拖动中 10 次查询 0.11ms、最终结果与参考实现逐位一致。⑤ **内存排查报告**(用户要求,第 4 项只排查未改动):弱峰调整工作缓冲实测 **≈34 B/采样**(极值表 `PeakExtremum` 24 B/极值为主导),**4 条工作线程并发 +412 MiB / 3M 采样** ⇒ 25.8M 采样峰值 ≈**3.5GB**(+数据 1.03GB +曲线缓存 ≤768MB ⇒ 4~5GB,与用户 4.4GB 观测吻合);**全仓无 `try`/`catch`** ⇒ 低内存时 `bad_alloc` 逸出线程入口 ⇒ `std::terminate()` 崩溃(无优雅降级机制)。验证:离屏 `uitest` **738/738 通过**(新增 `[AR]` 边沿计数预算/精确性/拖动期 O(1)、`[AS]` 标题栏 RAM 口径),`make release` 零警告。 |
| 2026-10-14 | DeepSeek-V4.1-Flash(**起源设备**) | **边沿计数改用「绘制值」+ 派生信号烘入时间偏移 + 图例托底统一为 GX 徽标款 + 调整二级面板向上展开(详见 §9.21)**。① **边沿计数恒为 0**:`EdgeCountsFor`/`EdgeCenterCountsFor` 只比原始采样值,而阈值是屏幕上的值 → 「系数 50」的信号统计恒为 0;修法:判定值改为 `ApplyDrawTransform(style, values[SourceIndexFor(...)])`(中心轴两侧同套变换;水平镜像时曲线窗口也要先映射下标);缓存新增 **`styleKey`**(否则改完「调整」仍命中旧计数)。② **区间边界点被误计**:`EdgeIndexRange` 左端“多取一个采样点”的外扩**已删除**,收紧为“**两个端点都在区间内**才计入”(光标恰落在越过阈值的点 B 上时 B 不再被算作上升沿)。③ **`offsetTime` 也烘进派生采样值**(**推翻 §9.19 的“唯一例外”**):按 `time[i]-offsetTime` 取最近采样点重采样(单调时间轴用只前进的游标,O(n);越界置 NaN),新信号样式**全默认**;非单调时间轴退回到把 `offsetTime` 带在样式上。④ **图例托底 = GX 徽标同款**(同高 / 圆角 = 高度一半 / 同配色 / 水平内边距 = 胶囊半径),文本改用新增字体 **`uiTiny`(10 逻辑 px)**;`kLegendPlateRadius` 已删。⑤ **`PopupMenu::Open(..., expandUp=true)`**(锤点为**左下角**、向上展开,`m_buildExpandUp` 供 `RebuildLayout` 复用)+ 「系数/偏移」「镜像」面板锤在按钮左上角再上移 `kAdjustMenuGap`。验证:`.preflight/uitest` 新增 `[AR]`(五组断言全部与**独立参考实现**逐位比对,含 A/B:“按原始采样值算会得到 7/6 个边沿”);**并同步修正 harness 里两处旧规则断言**(`[AP]` 派生信号继承 → 默认样式 + 烘值;`[AN]` 图例带无托底 → 装饰矩形逐块且暴露列可见底层内容);合计 **722/722 通过**,`make release` 零警告。 |
| 2026-10-15 | DeepSeek-V4.1-Flash(**另一台设备**) | **「调整」数值符号规则 + 边沿统计按帧推进(含首帧公平分摊)+ 浮动面板排版与弱峰复位控件(详见 §9.26)**。① **符号**:Y 轴偏移 0 不再显示 `+0.00`;系数非 0 正值带 `+`(编辑态初始文本仍无符号);`Δt:` 后按正负带符号(`Δt:+5s 60ms` / `Δt:-5s 60ms` / `Δt:0s`)。② **边沿统计卡住**根因:预算按 `GetTickCount64()` 分桶(系统粒度 15.6ms)而 `PostMessage` 自持重绘把帧率拉高 ⇒ 同一 tick 内多帧完全不推进 → 改为 **`Paint` 开头按帧重置**;并给**每个状态独立每帧配额**(`share = max(6, m_edgeScans.size())`,避免同帧新建状态被第一个独占整额)→ 实测首帧 5 个状态全部推进、**9 帧内全部扫完且不注入任何鼠标消息**、与参考实现逐位一致。③ **面板排版**:信号名宽度上限 `kEdgePanelNameMaxWidth=150`(300 字符名字只多 44px,旧实现多 5100px);统计信息**两列等宽**(`2×max(列宽)+间距`)⇒ 多信号“上升沿/下降沿”列起点固定、垂直对齐;未完成时**两个数字后都带 `…`**(旧实现只跟在“下降沿”后)。④ **弱峰调整**:每行滑块右侧新增**复位控件**(复用还原图标,悬停高亮 + `IDC_HAND` + 提示;点击恢复幅度 0 / 迭代 1 且上限回 10);上限输入框提示改为“输入上限(当前N)”;滑块标签去掉多余的 `%` / `N`。验证:新增离屏探针 `panelcheck` **46/46 通过**(全部走真实菜单/点击路径),既有 `verify` 47/47(另 2 条为本机未同步的旧规则断言),`make release` 零警告。 |
| 2026-10-15 | DeepSeek-V4.1-Flash(**另一台设备**) | **数据层大重构(文件→分组 / 时间轴一等公民 / 加载器抽象)+ CSV 多时间轴 + MF4 支持(详见 §9.27 与 `Documents/refactor_report_2026-10-15.md`)**。① **解耦**:`DataFile`→**`SignalGroup`**(删除 `filePath`/`fileName`/单一 `time`/`timeColumnName`/`timeMonotonic`/`minTime`/`maxTime`),新增 **`TimeAxis`**(1..N 条/组)与 `SignalData::axisIndex`,`SignalData` 删除冗余的 `rawHeader` 与**全仓无人使用的 `unit`**;`SignalRef::fileIndex`→`groupIndex`;`DataSet`/`TabConfig` 全套改名并新增 `Axis(ref)`;采样/统计 API(`SampleSignalAt`/`FindNearestSample`/`VisibleIndexRange`/`EdgeIndexRange`/`EdgeCountsFor`/`EdgeCenterCountsFor`/`EdgeScanFor`/`MeanFilterValueAt`/`WeakPeakValueAt`/`SampleSpacingPixels`)**首参由 `const DataFile&` 改为 `const TimeAxis&`**;`ResolvedSignal` 携带 `{group, axis}`。② **加载器抽象**:新增 `DataTable.hpp`(`DataTable`/`LoadProgress`/`TableBlock`/`LoadedSource`)、`DataLoader.*`(唯一入口 `LoadSignalSource` 按扩展名分发)、`CsvLoader.*`、`Mf4Loader.*`,删除 `CsvTable.*`;唯一装配点 `BuildSignalGroup` 与格式无关(新增格式只写加载器)。③ **CSV 多时间轴**:每个 `time` 列开启新块、各块按自己的时间列裁剪 ⇒ 支持各轴不等长(实测 3 条轴 7/4/7),**不再把其它 `time` 列当成信号**;时间轴判据收紧。④ **MF4 支持**:加载后新建分组、组名=文件名,全文件信号归入该组;每个通道组(CG)一条时间轴并自动去重(实测 27 CG → 3 轴 / 124 信号);实现 MDF v4 块解析(DG/CG/CN/CC/DT)、位级整数与 32/64 位浮点解码、线性换算 `phys = cc_val[0] + cc_val[1]*raw`、无效位处理;**`##TX` 兼容规范的「长度+UTF-16」与 CANape 的「裸 NUL 结尾文本」**(否则通道名全空)。⑤ 顺带修复:`Application::Initialize` 在窗口类已注册时不再弹模态框(旧实现导致同进程二次构造永久阻塞),`Shutdown` 注销窗口类;UI 文案统一到「分组」,对话框支持 `*.mf4/*.mdf`。验证:新增 `datacheck` **69/69**、`edgecheck` **46/46**(替代被编码破坏的 `panelcheck`)、既有 `verify` **47/47**(另 2 条本机旧规则断言)、`make release` 零警告。 |
| 2026-10-15 | DeepSeek-V4.1-Flash(**起源设备**) | **信号列表滚动越界绘制 + 边沿统计面板「显示冻结」(详见 §9.28)**。① **列表把组名/信号名画到筛选框上**(MF4 多信号场景):根因是 `UiText` 走 GDI `DrawTextW`,**GDI 文本不遵循 GDI+ 的 `SetClip`** —— 被滚出视口的「部分可见」行会把文字画到列表上方的筛选框上(`UiFillRect` 的大面积不透明矩形走 GDI `FillRect` 同理)。修法:`PaintUtil` 新增 **`UiTextClipped`**(同一 HDC `SaveDC` + `IntersectClipRect` + `RestoreDC`;`UiText` 转发 `clip=nullptr`,行为不变),`SignalPanel` 新增 `PaintRowText`(裁到 `m_listRect`)/ `FillRowRect`(夹到 `m_listRect`),分组行与信号行的全部文本与行底色均改走它们。② **边沿统计面板在不移动鼠标时显示冻结**:面板像素图层缓存键(`PanelSlotKey`)**不含**面板上显示的统计数字,而统计是**分帧推进**的中间结果 ⇒ 推进不会让键变化 ⇒ 面板一直贴旧像素 ⇒ 显示停在「统计中…」/中间值,**只有移动鼠标**(改变面板层序/悬停/鼠标坐标等其它键)才重绘并显示早已算完的正确结果(扫描推进本身没停)。修法:`PanelSlotKey` 内按面板实际显示的条目回调 `EdgeCountsFor`/`EdgeCenterCountsFor` 并把 `(rising, falling, complete)` mix 进键。验证:新增离屏 `.preflight/check`(`SW_HIDE` + 不注入真实输入 + `BELOW_NORMAL`)**39/39 通过**(连跑两次稳定):`[A]` 40 信号滚轮下滚后「列表上方区域逐像素 0 变化」且「列表内部仍有变化」(修复前 **1571 像素被污染**)、`[B]` 3 信号 × 300 万采样下两种统计面板的**缓存键持续变化**且**无鼠标输入即完成**(修复前键不变化)、`[C]` 真实 `MF4-Record.MF4`(**124 信号 / 3 条时间轴**)同样 0 像素变化;**并做了 A/B**(`git stash` 回退修复后 `[A]/[B]` 如实失败);`make release` 零警告。 |
| 2026-10-15 | DeepSeek-V4.1-Flash(**起源设备**) | **全局 Y 轴自适应跟随网格结构 + 组名悬浮 + 稀疏信号默认点线 + 单点光标吸附 + 放大自动点线(详见 §9.29)**。① **新网格不继承全局 Y 轴自适应**:`RebuildLanes` 对新出现的信号返回默认 `LaneCarry()` ⇒ `fitYToSelection=false`;修法:重建前先算 `previousAllAutoFit`(非空且**每个**网格都开着 = 全局开启的正式定义),`LaneCarry` 加 `matched` 标记,新信号用它当默认;并新增 `ReapplyGlobalAutoFitIfEnabled()` 在 **`Layout()`(网格数变化)/`RefreshLanes()`(信号数变化)/`SplitLane()`** 之后立即重算 ⇒ 新勾选(按**当前视界**自适应)、取消显示(剩余信号重新收敛)、拆分(每份各自自适应)三项全覆盖。② **组名悬浮**:新增 `SignalPanel::StickyGroupRowIndex()` + `PaintStickyGroupRow()`(不透明底 + 底部边界线),`RowRect` 加 `m_rowYOverrideActive/Y` 覆盖行顶部(行内所有几何的唯一来源);判据 = 分组行已在列表上边界之上且本组最后一行仍在列表内;常规循环**跳过**被钉住的行(否则画两次)。③ **稀疏信号默认样式**:新增 `ApplySparseDefaultStyle`(1 个有效点 → 点线 + **最大线宽 10px**;2..16 → 点线 + 2px),在 `BuildSignalGroup`/`InsertDerivedSignal` 两处调用;线宽常量统一到 `DataModel.hpp`(`kSignalLineWidthMin/Max`)并被 SignalPanel 滑块引用;`ComputeStats` 输出 `firstFiniteIndex`、`SignalData` 新增 `singlePointTime`。④ **单点光标吸附**:`SnapTimeToSample` 新增半径分支(`kCursorSnapPointRadiusPixels=8` 逻辑像素)吸附 `singlePointTime`,半径外自由移动(**吸附而非卡住**)。⑤ **放大自动点线**:`SignalStyle` 新增 `autoScatterActive/autoScatterSavedMode/autoScatterUserOverride`,`AxisSparseInView`(间距 > 半个绘图区宽度,与吸附上门限同源,**单点轴不算稀疏**)+ `UpdateAutoScatterStyles()`(在 `SetTimeView` 等四处调用)⇒ 进入阈值自动切点线、退出阈值恢复原样式,**用户手动改过则不恢复**(判定必须放在恢复判断**之前**,否则用户改完紧接着缩出去会被覆盖)。验证:新增离屏 `.preflight/check` **65/65 通过**(连跑 3 次稳定,覆盖 7 组断言;含“钉住行与自然位置逐像素一致”“单点吸附半径内外”“用户手动改样式后退出不覆盖”),**A/B**:临时禁用六个机制后 **21 项如实失败**;`make release` 零警告。 |
| 2026-10-15 | DeepSeek-V4.1-Flash(**起源设备**) | **组名平滑悬浮 + 单点信号初始视图 + 光标吸附统一判定(自动点线同源)(详见 §9.30)**。① **组名悬浮平滑化**:位置改为 `StickyGroupRowTop = min(列表上边界, 本组最后一行下边界 − 组名高度)` ⇒ 组名随最后一行**自然滚出**(不再突然消失),且下边界永不越过下一个分组行的自然位置 ⇒ **不再遮住下一组名**(旧实现固定钉在列表上边界会把下一组名盖住,让别人以为后面的信号属于上一组);**折叠的分组不悬浮**(随滚动自然划过);底部边界线只在真的“浮”起来时才画。② **单点信号初始视图**:新增 `PlotView::FitSinglePointSignal()`(无任何信号被勾选、勾选的第一个信号只有 1 个有效点时生效)—— 数据点居中、**时间跨度 1s**、**Y 跨度 1**,并置 `autoRange=false`/`fitYToSelection=false`;`Application::SynchronizeView` 首次勾选时先试它、失败才 `FitAll()`;`AutoRangeLaneToView` 的退化区间改为 `PadRange` 同款留白 `max(1,|v|×0.1)`。③ **吸附判定统一**(用户给出的唯一判据):新增 `GridMinorCellCount()`(网格一横排小格数 N,与网格绘制同源)+ `SignalSnapEnabled()`(**当且仅当**某信号在当前一屏内的数据点 `< N×4` 才开启吸附,随视界实时重算 ⇒ 疏密信号自适应),`SnapTimeToSample` **只按鼠标所在网格**判定、同网格内所有符合判定的信号数据点都参与、吸附半径 = 一个网格小格宽且不超过半个局部采样间距(吸附≠卡住);旧的 `SampleSpacingPixels`(6px 下门限+半屏宽上门限)**已删除**。④ **自动点线改为与吸附判定完全同源**(`UpdateAutoScatterStyles` 调 `SignalSnapEnabled`),旧 `AxisSparseInView` **已删除**;进入阈值自动切点线、不再满足自动恢复、用户手动改过则不恢复。⑤ **需求作废**:“2…16 个有效采样点默认点线 + 2px”**已取消**(只保留“1 个有效点 → 点线 + 最大线宽”);`SignalData::singlePointTime` 升级为 **`sparseTimes`/`sparseValues`**(有效点 ≤ 1024 时全部缓存,供吸附与单点视图使用)。验证:新增离屏 `.preflight/check` **55/55 通过**(连跑 3 次稳定;覆盖组名悬浮四种场景、单点初始视图、吸附判定/网格作用域/多信号/动态切换/单点半径、自动点线同源与用户覆盖);**A/B** 禁用三项机制 ⇒ **14 项如实失败**;`make release` 零警告。 |
| 2026-10-15 | DeepSeek-V4.1-Flash(**起源设备**) | **自适应范围/时机澄清 + 适应视图与 Y 轴自适应互不干扰 + 吸附跟随时间偏移(详见 §9.31)**。① **澄清“自适应范围/时机”**(修正记忆里“额外要求视界内至少有一个可见数据点”这句不完善的表述):**首个网格**显示内容时 **Y 轴与时间轴都**自适应;只有一个数据点的信号要**居中显示在网格里**(`FitSinglePointSignal`:居中 + 时间跨度 1s + Y 跨度 1);**后续新增网格只做 Y 轴自适应、不再调整时间轴**。② **「适应视图」与「Y 轴自适应」互相干扰**:根因是两条自适应路径**留白不同**(`PadRange` 6% vs `AutoRangeLaneToView` 5%)⇒ 视界未变时也会算出不同区间、网格被无意义重绘(实测切一次 **143513** 像素、反向 **137326** 像素)。修法:新增 `DataModel.hpp` 的 **`PadValueRange()`** 作为唯一留白实现(含退化区间 `max(1,|v|×0.1)`),`PadRange` 与 `AutoRangeLaneToView` 共用 ⇒ 四个步骤(适应视图→开自适应→关自适应→再适应视图)**区间逐位相同且绘图区逐像素 0 差异**。③ **「适应视图」在单点信号上让数据点“飞了”**:`FitAll()` 开头先试 `FitSinglePointSignal()`(仅一个网格 + 唯一可见信号为单点),与首次加载效果一致且幂等。④ **吸附跟随「调整 → 时间轴偏移」**:采样点画在 `time[k] + offsetTime` 处而光标存的是显示时间,旧实现按源时刻吸附 ⇒ 落到历史位置;修法:`SignalSnapEnabled`/`HasVisibleSample`/`NearestSnapSample`/`LocalSampleSpacing` 全部加 `timeShift`,查找时 `time - timeShift` 回源空间、返回时加回去,`UpdateAutoScatterStyles` 同步传 `offsetTime`。验证:离屏 `.preflight/check` **39/39 通过**(连跑 3 次稳定;含像素级零变化断言与“真缩放会重绘”对照组);**A/B** 回退三项修复 ⇒ **7 项如实失败**(含 143513/137326 像素重绘症状);`make release` 零警告。 |
| 2026-10-15 | DeepSeek-V4.1-Flash(**起源设备**) | **浮动面板不穿透 / 网格高亮改由信号列表触发 / 样式区「目标」行 / 联动平滑滚动(详见 §9.34)**。① **浮动面板上双击穿透到网格**:`PlotView::OnDoubleClick` 在所有下层命中都不成立时无条件 `FitAll()` ⇒ 在面板上双击会让网格莫名其妙自适应一次;修法:**最前面**用 `TopPanelAtPoint()` 判定并消费。② **网格变色高亮的触发来源重新定义**(旧行为"鼠标在网格里移动就高亮"**作废**):改为**信号列表 / 样式区「目标」行悬停到某条已绘制信号** ⇒ 该信号所在网格变色高亮**且该信号曲线本身也高亮**(新增 `m_externalHoverSignal`,与 `m_hoverSignal` 分开保存,由 `Render()` 每帧同步;新增 `EffectiveHoverLane()`)。③ **样式区「目标」行**(样式自定义区最上方):显示当前编辑的目标信号名称 + (已绘制时)名称**后方**的 GX 标签,悬停高亮并联动网格/曲线高亮,**单击不编辑、双击改名**。④ **联动平滑滚动**:绘图区选中信号(点图例 / 点曲线 / 双击图例)与样式区任何交互都触发 `ScrollToSignalAnimated()`(ease-out cubic、时长 `clamp(距离×0.35ms,130,300)`、**上行让出组行高度避免被悬浮组名遮挡**、滚轮/拖滚动条即取消)。验证:离屏 `.preflight/check` **73/73 通过**(连跑 3 次稳定;含浮动面板双击不移视界 vs 空白处双击必自适应的对照组、未勾选信号悬停不高亮、目标行 GX 标签位置与单击/双击区分、长列表 122 行平滑滚动与遮挡检查);**A/B** 回退六处实现 ⇒ **15 项如实失败**;`make release` 零警告、链接通过。 |
| 2026-10-15 | DeepSeek-V4.1-Flash(**起源设备**) | **「适应视图」边界规则撤回重做:单值散点让出半径 + 单点簇视图(详见 §9.33)**。① **初次自适应没有发生**(用户报告):`FitAll()` 在**没有任何可见信号**时也把 `ViewInitialized()` 置 true ⇒ 随后勾选第一个信号时 `SynchronizeView` 认为已初始化、**跳过自适应**(实测 MF4 视界停在 42.386s,数据点被推到右边缘外);修法:只有存在可见信号才置位,并让初次自适应与工具栏「适应视图」**共用 `FitAll()` 一条路径**。② **单值散点被裁掉半个**(用户报告):旧规则"左右各留一小格"在"单值 + 连续信号"下既不准也不够(`WIN_Motor_StallCurrentVolBrk` 只有 1 个有效点,正好落在边界上);按用户要求**撤回旧策略**(删除 `FitSinglePointLanes`/`TimeGridCellSeconds`/`LaneValueGridCell`/`FitSinglePointSignal`),改为:**任何一条边界被单值散点顶住就让出该散点的半径(+1px)**,只在散点**会被裁切**时才扩展(内部散点不动视界),边界不是单值散点时边界就落在信号点上。③ **特例**:全部可见数据点都是单值信号且时刻相同 ⇒ 时间轴以该时刻居中、缩放到**一大格 = 1s**(视界 10s,`NiceStep(10,10)==1.0`),每个网格的幅值区间保证其数据点都能完整画出(退化成单值时跨度 1 居中)。验证:离屏 `.preflight/check` **62/62 通过**(连跑 3 次稳定;含**真实文件** `TestData/MF4-Record.MF4`:`WIN_Motor_StallCurrentVolBrk`(1 点) + `m_MOT_Current_DCom[0]`(8475 点) 复现用户场景,散点圆心离绘图区左边界 16.5px、半径 15px ⇒ 完整可见);**A/B** 回退四处规则后 **20 项如实失败**(含"散点正好压在绘图区右边缘"、"初次视界 42.386s");`make release` 零警告、链接通过。 |
| 2026-10-15 | DeepSeek-V4.1-Flash(**起源设备**) | **信号面板滚动条跟随 / 工具栏连点丢状态 / 图层网格线步长 / 多网格单点自适应 / 单点信号拖拽(详见 §9.32)**。① **滚动条"卡住"**:`Application::WM_MOUSEMOVE` 的 `plotOnlyMove` 只看 `inPlot && m_mouseOverPlot`,拖滚动条进绘图区后判定"只影响绘图区"⇒ 面板滚动值变了却不重绘;修法:新增 `draggingNonPlot`(正在拖非绘图区组件时一律完整失效)。② **工具栏丢点击**:窗口类带 `CS_DBLCLKS` ⇒ 快速第二次按下被合成 `WM_LBUTTONDBLCLK` 而工具栏没处理(**按下丢失**);且 `HandleMouseMove` 只要不在工具栏上就 `OnMouseLeave()` ⇒ 清掉 `m_pressedIndex`(按住划出再回按钮松手无效);两处都已修。③ **网格线错位/丢失/闪烁**:`PaintLane` 的竖向主刻步长取自 `Tab().ViewMin/ViewMax`,而渲染信号图层时该区间已被改成**宽窗口**(≈2×)⇒ 图层辅助线步长 2×、位置与前台不同、判重判据也错位;修法:新增 `m_renderTimeStep`,由 `RenderLaneLayerRegion` 用**前台跨度**算好传入。④ **多网格单点「适应视图」**:新增 `FitSinglePointLanes()`(左右边界 = 最左/最右数据点 ± **一个网格小格**;逐网格 Y 自适应、各留**一个 Y 小格**;全部点同刻 ⇒ 跨度 1s 居中;单值网格 ⇒ Y 跨度 1 居中)。⑤ **单点信号**:`HitTestSignal` 新增单点分支 + 环状光晕 + 可拖拽换网格(悬停**不改光标**,按下后才改)。验证:离屏 `.preflight/check` **67/67 通过**(连跑 3 次稳定);**A/B** 回退六处修复 ⇒ **17 项如实失败**(含 [C] 辅助线 49px vs 25px、20 vs 40 条、最大偏移 539px);`make release` 零警告、链接通过。 |
| 2026-10-16 | DeepSeek-V4.1-Flash(**另一台设备**) | **分组折起/展开动画 + 筛选态可折起(项数按筛选计数)+ 悬浮组名可交互与"先滚动再折起" + 「适应视图」与「Y 轴自适应」彻底同源(详见 §9.35)**。① **折起动画**:`SignalPanel::m_groupAnims` + `GroupRowScale()` —— 每行速度倍率 `1 + 1.2×ordinal/(行数−1)`(**离组名越远越快收尽**)叠加 ease-out cubic(末段减速),总时长 `clamp(115+14×行数, 140, 300)` ms;`BuildRows` 输出**非整行高**,低于 0.75 逻辑 px 的行不再产生;宿主 `WM_TIMER` 同时驱动滚动与折起动画(`Render` 判定改为二者取或);新增 `SetRowClip/ClearRowClip` 把行内容裁到本行(否则垂直居中文本溢出到相邻行)。② **筛选态折起**:筛选模式改用**独立**的 `m_filterCollapsed`(默认展开,不污染普通列表),`ListRow::displayCount` 承载"该组内符合筛选条件的个数",筛选文本/清空/数据变化即清空。③ **悬浮组名可交互**:根因是 `HitRow` 只按滚动偏移映射行 ⇒ 点击穿透到下层;修法:`HitRow` 先用 `StickyGroupRowIndex()`+`StickyGroupRowTop()` 判定悬浮行矩形并临时用 `m_rowYOverrideActive` + 新的 `RowHitPart()` 算行内区域,并跳过高度为 0 的行;新增 `ToggleGroupFromRow()`:正悬浮时 `StartScrollAnimationTo(行内容偏移, groupIndex)`(挂起请求 `m_pendingGroupToggle`),滚动到位后才启动折起动画。④ **单点信号上两种自适应不一致**:`FitSinglePointCluster` 曾另算"半径留白 + 跨度 1"且把 `fitYToSelection` 置回 false ⇒ 点「适应视图」既改区间又**关掉 Y 轴自适应**;修法:单点簇只负责时间轴(居中 + 一大格 1s),**逐网格幅值轴与常规路径/Y 轴自适应完全同源**(`fitYToSelection` → `AutoRangeLaneToView`,否则 `autoRange=true`,再接 `ApplySinglePointValueMargins`),且**绝不改写** `fitYToSelection`。验证:新增离屏探针 `.preflight/perf/panelanim_main.cpp`(真实 `Application` + `SW_HIDE`)**94/94 通过**(连跑 3 次稳定;覆盖折起/展开的分帧过渡、单调收敛、越靠下越矮、末态精确无残留、筛选计数与独立折起状态、悬浮四个可交互区域全部命中且下层行仍可点、点击后"先滚动到位再折起"、真实 MF4 单点信号两种自适应逐位一致/往返稳定);**A/B** 三组(回退绘图区修复 / 禁用悬浮命中优先 / 动画时长置 0)分别 **4 / 13 / 8 项如实失败**;既有 `verify` **49/49**(顺带修正 4 条与远端新规则不符的过期断言)、`datacheck` **69/69**、`edgecheck` **46/46**;`make release` 零警告。 |
| 2026-10-16 | DeepSeek-V4.1-Flash(**另一台设备**) | **动画改「抽拉卷尺」式刚性平移 + 筛选态折叠三角修正 + 样式区「目标」行绘制/布局修正(详见 §9.36)**。① **动画重做**(推翻上一轮的“按行缩放”):整组信号作为**刚性整体**平移,**行高永不变**、只维护一个逐行平移量 `m_rowShift` ⇒ 动画期间**无需重建行集合**(流畅关键);收起时逐条被组名栏吞掉、展开时逐条冒出;中途反向点击**从当前位移续接**不跳变;时长 `clamp(115+6×行数, 150, 320)` ms。② **藏进组名栏的裁剪**:信号行越过组名的**绘制**下边界即裁掉,且除 GDI 裁剪外**必须用 `g.SetClip(..., CombineModeIntersect)` 同时收紧 GDI+ 裁剪**(否则行内色块/复选框/徽标画到组名栏上)。③ **悬浮组名折起改为“一边滚动一边收起”**(推翻“先滚动到位再折起”):`scrollSynced` 让滚动与平移按同一进度推进 ⇒ 组名屏幕位置恒定;结束时滚动偏移恰好落在“组名正好在最上方”,并且**无跳变**;旧 `m_pendingGroupToggle` 机制删除。④ **筛选态折叠三角**:`PaintFileRow` 改读 `IsGroupCollapsed()`(筛选模式用的是独立的 `m_filterCollapsed`),修复“三角维持筛选前状态”。⑤ **「目标」行**:`PaintTargetRow` 误用列表裁剪版 `PaintRowText` ⇒ 名称与 GX 文本被完全裁掉(改用 `UiText`);GX 标签改为**右对齐到内容列右边缘**、名称无标签时占满内容列;`EditFieldRect()` 对目标行只返回名称矩形(否则改名输入框把 GX 标签包进去)。验证:离屏探针 `.preflight/perf/panelanim_main.cpp` **147/147 通过**(连跑 3 次稳定;含逐帧断言组名栏像素零变化、`m_rowsDirty` 恒 false、滚动单调且恰好落位、筛选/普通态折叠三角逐像素 0 差异、目标行文本像素存在与两端对齐);**A/B** 五组(回退筛选三角+非刚性平移 / 回退目标行绘制与布局 / 关闭同步滚动 / 去掉 GDI+ 收紧裁剪 / 不续接位移)分别 **4 / 6 / 6 / 2 / 1 项如实失败**;`verify` **49/49**、`datacheck` **69/69**、`edgecheck` **46/46**;`make clean && make all` 零警告。 |
| 2026-10-16 | DeepSeek-V4.1-Flash(**另一台设备**) | **分组折起热区与改名输入框修正 + 数据模型去冗余(单一名称)+ 新增 `Components/Shared` 组件 + 过时注释/命名大清理 + 二进制瘦身 24.6% 并完成多文件拆分(详见 §9.37 与本轮报告 `Documents/refactor_report_2026-10-16.md`)**。① **折起热区**(用户要求):点分组**名称**不再折起,只有**折叠三角**或**「组X」标签**(新增 `RowPart::GROUP_TITLE`)才折起。② **分组改名输入框不再吞掉「N 项」**:`RowNameRect` 右边界改为项数左缘;新增 `GroupLabelText`/`GroupStatsText` 作为文本与宽度的唯一来源。③ **`SignalData`/`SignalGroup` 只留一个名称字段**:删除 `displayName` 与 `Title()`(31 处改用 `name`),`SetSignalDisplayName`/`SetGroupDisplayName` → **`RenameSignal`/`RenameGroup`**(空名一律拒绝);删除死成员 `Application::m_loadingBytes`。④ **新增 `Components/Shared`**(用户要求):从 `PaintUtil` 迁出全部**控件**(图标/复选框/滑块/内联输入框/颜色样块/取值胶囊配色)到 `UiControls.hpp/.cpp`,`PaintUtil` 只留低层原语;依赖方向固定 `Components/* → Components/Shared → Utilities`。⑤ **过时注释/命名**:`文件下标`→`分组下标`、`文件分组`→`分组` 等一批;形参名 `file`→`axis`/`group`;`PaintFileRow`→`PaintGroupRow`、`FileCloseRect`→`GroupCloseRect`、`OpenFileMenu`→`OpenGroupMenu`、`FileGroupLabelWidth`→`GroupLabelWidth`、`StyleEditField::FILE_NAME`→`GROUP_TITLE`;修复 `DataModel.hpp` 两处被压扁的注释块与 `NewTab` 重复文档块。⑥ **多文件拆分(本轮完成)**:`PlotView.cpp`(13 695 行)+ `SignalPanel.cpp` + `Application.cpp` + `DataModel.cpp` 四个大文件全部按功能拆成多个协作文件(私有内部头 + 具名命名空间 + 全 `inline` + 按名字分桶),最大单文件 544 KB → 276 KB。⑦ **二进制瘦身**:真因是 **ICO 里 256×256 未压缩 BMP 条目(270 KB,占整包 27%)**,改为 **PNG 存储**(观感零变化)⇒ `Target.exe` **1 013 248 → 764 416 B(−24.6%)**;实测“拆分不会减小机器码,反而 +1.4%”、`--gc-sections` 反而 +9 KB(已回退)。验证:`panelanim` **156/156**、`verify` **49/49**、`datacheck` **69/69**、`edgecheck` **46/46**;**A/B** 回退两处 UI 修复 **10 项如实失败**;`make release`/`make debug` 零警告。⚠️ 该提交当时的待办(`PlotView.cpp` 等大文件多文件化拆分、`LanePanelState` 抽取、模态提示解耦)**已全部在紧随其后的两个提交里完成**:拆分 → 提交 **`e6b7bf6`**(详见 §9.37 第七节),`LanePanelState` + 模态解耦 → 提交 **`d2e6fdd`**(详见 §9.37 第八节)。**本行不再表示任何未完成项。** |
| 2026-10-16 | DeepSeek-V4.1-Flash(**另一台设备**) | **收尾:`LanePanelState` 抽取 + 错误提示非模态化(把上一行的「未完成」清零,详见 §9.37 第八节与报告 `Documents/refactor_report_2026-10-16.md` §九)**。① **`Lane` 的 12 个平铺面板字段**(`edgePanelOpen/X/Y` × 4 类实验性功能)→ **4 个 `LanePanelState`**(`struct LanePanelState { bool open=true; double x=-1; double y=-1; }`),访问点全部改为 `lane.edgePanel.open` 形式;`RebuildLanes` 的 `LaneCarry` 同步收敛为 4 个 `LanePanelState`(逐字段赋值 → 整体赋值)。⚠️ 教训:**批量改名脚本不能对「声明」与「使用」跑同一组替换对**(会生成 `bool edgePanel.open = true;` 非法声明),声明处须单独重写。② **错误提示与模态阻塞解耦**:新增 `Application::SetModalErrorsEnabled(bool)`/`ModalErrorsEnabled()`(成员默认 `true`),`Initialize` **最先**执行 `SetModalErrorsEnabled(showCommand != SW_HIDE)`(隐藏窗口弹模态框 = 进程被钉死);`SetStatusMessage(err,true)` 允许模态时原样 `MessageBoxW`,否则走 `m_plot.ShowPersistentHint(text)`(绘图区底部非模态浮层);`PlotView` 新增公开 `ShowPersistentHint`。普通提示/空文本仍被忽略 ⇒ **交互行为不变**。③ **顺带修掉真实缺陷**:`PlotView::Paint` 在 `m_lanes.empty()` 时提前 `return`,提示浮层在其后绘制 ⇒ **未勾选任何信号时错误永远不可见**(正是"加载失败"场景),该分支补一次 `PaintHint(g)`。④ **新增探针 `.preflight/perf/errcheck` 19/19**:默认值/隐藏降级/宿主开关、**工作线程 + 5s 超时**判"不阻塞"(旧行为会永久阻塞 ⇒ 判失败并 `ExitProcess`,探针不挂)、`EnumWindows` 数本进程 `#32770` 顶层窗口数不变、提示文本/粘滞/需重绘/起始时刻、**像素级**"有/无提示"差异 3781 且全帧差异同为 3781。验证:`make release`/`make debug` 零警告,`Target.exe` **764 416 B**;`verify` 49/49、`datacheck` 69/69、`edgecheck` 46/46、`panelanim` 156/156。⑤ **新增结构检查** `Tools/structcheck.ps1` + `make structcheck`(**93/93**):[1] 四个巨型文件已按职责拆分(`PlotView.cpp` 13 695 行 → 705 行,`nm`:绘制符号只在 `PlotViewPaint.o`);[2] `Components/Shared` 16 个控件全部由 `UiControls.o` 定义、69 处调用跨 ≥3 个组件 + 分层方向;[3] 本轮三项用户要求的源码级断言(折起热区、改名框不吞项数、`SignalData` 单一 `name` 字段 + 注释核验)。**判别力 A/B**:回退两处 UI 修复 ⇒ `panelanim` 147/156(9 项如实失败,文案点名“group rename box stops before the item counter”“clicking the group name does not collapse the group”)、`structcheck` 90/93;恢复后 156/156 与 93/93。 |
| 2026-10-16 | DeepSeek-V4.1-Flash(**起源设备**) | **五项交互修复:折起后滚动条 / 单点信号样式 / 线宽实时生效 / 网格菜单限高滚动 / 点选信号滚动绘图区(详见 §9.38)**。① **分组折起后再展开滚动条消失**:`AdvanceGroupAnimations` 只在折起结束时重建行集合,而展开结束那一帧 `m_contentHeight` 跳回展开态却无人重算滚动条可见性 ⇒ 滚动条停在“隐藏”。修法:任一方向结束都 `m_rowsDirty = true` + `BuildRows()`,并**每帧** `RefreshScrollBar(false)`(动画期间不夹取滚动偏移,避免打断 `scrollSynced`)。② **单点信号默认绘制方式改「散点」**(原「点线」),且信号面板「样式」行的「线段」「点线」对它**置灰且不可点**(新增 `ModeOptionEnabled`,也不参与悬停高亮);`UpdateAutoScatterStyles` 跳过单点信号,避免“自动点线”与置灰状态自相矛盾。③ **拖动线宽滑块完全不生效**(用户报告:拖动时不变、松手瞬间变了、再移动鼠标又变回去)——根因两个:**`AcquirePolyline` 的缓存键漏了 `lineWidth`**(而 `BuildPolyline` 把线宽烘进包络带几何:逐列上下包络各外扩半个线宽,「梳状」判定也依赖它),以及**样式补丁没有同步宽基底图层**(下一帧宽基底快路径把新样式覆盖回旧像素)。修法:`PolylineCacheEntry` 新增 `lineWidth` 并进比较;样式补丁后 `QueueLaneRebuild(patchLanes) + RenderPendingLaneRebuilds(1)`。实测常量信号列厚 `0.5px→1px / 5.3px→7px / 10px→12px` 且松手与移动鼠标后**保持 12px**。④ **「目标」行的 GX 标签复用「所属网格」菜单**(与列表行徽标同一份),并先给 `PopupMenu` 加**限高 + 条目区滚动 + 尾部固定项**(`MenuLayoutConstraint::maxHeight`;固定区 = 最后一个分隔线及其后的「新建网格」;滚轮滚动、键盘移动自动滚入可视区;滚动区文本用 `UiTextClipped` 并跳过不可见条目;`ItemAtPoint` 增加滚动视口判定)——顺手解决“上百个网格时菜单超长且交互卡顿”;GX 标签点击**不参与**联动滚动,悬停联动保留。⑤ **点选信号名 ⇒ 绘图区自动把目标网格滚到可视区内**(`RequestPlotLaneScroll` / `ApplyPendingPlotLaneScroll` / `PlotView::ScrollLaneIntoView`):在上方就上滚、在下方就下滚、已可见则不动;**悬停不滚动**。验证:新增离屏 `.preflight/check`(`SW_HIDE` + 合成消息 + `BELOW_NORMAL`)**68/68**;**A/B** 五处修复全回退 ⇒ **17 项如实失败**(A 组需逐帧重绘驱动,单独回退 ⇒ 5 项);`make release` / `make debug` 零警告。⑥ 按要求**删除 `Tools/`(`structcheck`)并移除 `make structcheck` 目标**(§9.37/§11.2 第 96 条的“可复跑”描述自此失效,仅作历史证据)。 |
| 2026-10-16 | DeepSeek-V4.1-Flash(**起源设备**) | **「所属网格」菜单标题固定 + 10 行上限 + 滚动条;网格滚动联动改为动态过渡(详见 §9.39)**。① **滚动时标题跟着滚掉**:上一轮只实现了尾部固定区(分隔线 + 「新建网格」钉底),滚动区从**条目 0** 开始 ⇒ 标题被一起滚走;修法:新增 `m_scrollFirst`(前导 `HEADER`/`HEADER_ACTION` 个数),滚动区变为 `[m_scrollFirst, m_pinnedIndex)`,标题从菜单顶向下固定排;`Paint` 裁剪/跳过、`ItemAtPoint` 视口判定、`EnsureItemVisible` 下界全部改用该区间,并新增 `ScrollRegion(first,last)` 查询。② **长度上限太大**:`MenuLayoutConstraint` 新增 `maxScrollRows`(**取 10 行**,与 `maxHeight` 取更严格者)⇒ 视口高恒为 10×行高;**新增滚动条**(轨道贴滚动区右内侧、滑块按视口比例、滚动区条目宽度让出滚动条宽度),且**可拖拽**(按下滑块/点击轨道开始拖、拖动只改偏移不改悬停、抬起结束且不下发命令、菜单保持打开),新增 `ScrollOffsetForThumbTop`。⚠️⚠️ 教训:**`BuildLayout` 绝不能复位 `m_scrollBarDragging`**(拖拽时每帧都 `RebuildLayout()`,清零会让拖拽“拖一下就脱手”)。③ **网格滚动联动旧实现是“瞬移”**:`ScrollLaneIntoView` 现在只**启动平滑动画**(`m_laneScrollAnimActive/From/To/StartMs/DurationMs`;时长 `clamp(距离×0.35ms,130,300)` + ease-out cubic,与信号列表同源),由宿主 16ms 定时器调用 `AdvanceLaneScrollAnimation()` 逐帧推进(`Render` 的定时器判定加入 `LaneScrollAnimationActive()`);用户滚轮/拖滚动条会 `CancelLaneScrollAnimation()`。实测 154 网格、距离 12091px 的动画约 20 帧完成并精确落位。验证:`.preflight/check` **95/95**;**A/B** 回退三处 ⇒ **10 项如实失败**;`make release`/`make debug` 零警告。 |
---
| 2026-10-16 | DeepSeek-V4.1-Flash(**本机**) | **回退 5b52eef 并修复“屏幕不刷新”根因(拖动滚动条卡顿 / 悬停高亮卡住 / 信号拖拽无效果,详见 §9.41;§9.40 随之作废)**。根因(代码级):`Render()` 的回送被写成 `if (fullFrame) … else if (panelOnly) …` 而**没有兜底 else**,而“鼠标只在绘图区内移动”的帧恰好两个条件都不满足 ⇒ 后备缓冲区画对了、窗口 DC 一个像素都不回送(悬停光晕 / 滑块 / 拖拽结果全部停在旧像素)。修法:`git reset --hard` 撤销该提交(留 `backup-bad-commit` 标签),只挑回与更新区域无关的功能改动(联动前展开折起组 / 适应视图不动滚动条 / 「回到最上方」按钮 / 网格滚动平滑动画),彻底删除 `m_paintPlot`/`m_panelDirty`/`panelOnly`/并集回送;宿主只保留 3 处最小改动且 `Render` 的绘制与 BitBlt 与 `82b5ebe` 逐字节相同。验证:`.preflight/diag` **19/19**(新增离屏可见窗口探针:滚动条拖动 / 悬停光晕出现与消退 / 信号拖拽换网格)、`.preflight/check` **137/137**、源码护栏 `.preflight/diag/verify_blit.ps1` **10/10**(A/B:换回坏版本 ⇒ 4 项如实失败)、`make release` 零警告。教训:① 离屏探针**测不到窗口回送**(屏幕外 DWM 不合成、窗口 DC 恒空白)⇒ 回送类约束必须有源码护栏;② 离屏窗口 + `TrackMouseEvent` 会**立刻**收到 `WM_MOUSELEAVE`,探针须丢弃该消息;③ 网格回收会让下标左移,断言要用锚信号。 |
| 2026-10-17 | DeepSeek-V4.1-Flash(**本机**) | **网格底衬永不空白 + 动画流畅性根治 + 光标随首个信号重现 + 「放大自动点线」新判据(详见 §9.42)**。① **网格底衬消失**:旧跨度图层的 `StretchBlt` 占位拉伸在深度放大时把一小段像素放大几十倍 ⇒ 整块网格变成**无网格的模糊色块**;叠加待重画队列**按下标顺序**消费(可视网格要等上百帧)⇒「消失 + 要等一会才刷新好」。修法:**删除占位拉伸**,改为对拿不到像素的网格**就地补画网格底衬**(`PaintLaneGridBacking` + `m_renderGridOnly`,代价极低可每帧执行),队列改**可视网格优先**,结算判据改 `LanesVisiblyReadyForView()`。② **动画卡顿(两处根因)**:**(a)**「`m_layerRebuildPending` 非空就每帧请求再送一帧」在上百个网格时挤出 100+ 次重绘、把优先级更低的 `WM_TIMER` **饿死**(实测 `frames=124/timerTicks=1`)⇒ 改用 `AnyVisibleLanePending()`(`frames=25/timerTicks=16`);**(b)** `SetTimer(...,16,...)` 被系统 tick **量化成 ~26ms(≈38fps)** ⇒ 周期改 **10ms**(实测 ~15.5ms ≈ 64fps,真实消息循环帧距 ~25ms → ~16ms);**(c)** 网格滚动量原本参与「宽基底可用性」判据 ⇒ 滚动动画每帧整幅重绘 ⇒ 新增 `SceneContentHash(..., includeLaneScroll)`(判据不计、场景缓存仍计),单帧 **18.36ms → 1.19ms**。③ **光标随首个信号重现**:新增 `ResetCursorPlacementOnFreshContent()`(与 `CycleCursorMode()` 首次启用同源),接入 `SynchronizeView` 的「首次出现可见信号」分支。④ **「放大自动点线」新判据**:任一小格内数据点 < 5 即切换(`AutoPointLineNeeded`;二分/鸽巢封顶);⚠️ 光标吸附仍用旧整屏判据(用户要求"其它规则不变")。验证:新增 `.preflight/perf/gridanim_main.cpp` **55/55**(含真实消息循环帧距统计、光标重现、点线规则);`verify` 49/49、`panelanim` 156/156、`datacheck` 69/69、`edgecheck` 46/46、`errcheck` 19/19;`make release` 零警告。⚠️ 教训:`cardBg == plotBg` ⇒ 颜色判据色盲;后备缓冲区持久 ⇒ 「看不到空白」不能证明没问题;网格分界线会让「有锐边」判据假绿(须要求同列多行)。§11.2 新增第 117–124 条。 |
| 2026-10-17 | DeepSeek-V4.1-Flash(**本机**) | **滚动过渡动画根治 + 快速连点跟手 + 分组名单击展开 + 光标吸附同源 + 按钮淡出对齐 + 滚动位置复位(详见 §9.43)**。① **滚动动画卡顿(三处根因)**:**(a)** `WM_TIMER(kScrollTimerId)` 无条件整窗失效 ⇒ `WM_PAINT` 把 `m_chromeDirty` 置真 ⇒ **每帧重绘标签栏/工具栏/面板**(绘图区动画帧 13.6→**3.5ms**;手动拖滚动条参照 4.2ms);**(b)** 图层预热不足 ⇒ 长距离滚动扫进未建图层的网格、每帧靠底衬重画十几份网格(+8ms/帧)⇒ 可视判定加 **±1 屏预取带** + `PrefetchStepAllowed()` 限速后台预热(`lane-only+VERY_BIG_DATA` 13.9→**5.3ms**);**(c)** 网格滚动量参与宽基底可用性判据(上一轮已修)。② **光标吸附改用「自动点线」同一判定**:`AutoPointLineNeeded` 改名 `MinorCellSparse`,吸附与自动点线共用;判定**不看绘制方式** ⇒ 用户改过样式也吸附;删除旧整屏判据 `SignalSnapEnabled`。③ **「回到最上方」按钮淡出与浮动提示一致**:`kBackToTopFadeOutMs = kHintFadeMs`(500ms) + **线性** alpha + 到达顶部立即淡出(实测 50% 时刻 alpha=0.500,旧 ease-out 为 0.875)。④ **快速连点跟手**:`CS_DBLCLKS` 会把快速连点的第 2 次按下合成为 `WM_LBUTTONDBLCLK`,旧实现只给工具栏打补丁 ⇒ 复选框/全部显示隐藏/绝对值/组X/折叠三角/浮动面板复选框的按下**丢失**;新增 `DoubleClickIsPlainPress(x,y)`(`PlotView` + `SignalPanel`),宿主据此选择走 `OnLButtonDown`。⑤ **分组行:名称恢复单击展开收起**(推翻 §9.37 ①),只有**快速双击**(`kGroupRenameDoubleClickMs=300`,比系统 500ms 紧)才进入改名,且进入前**撤销**按下那次的折叠切换。⑥ **信号重现时网格滚动回到最上方**(`SynchronizeView` 首次出现可见信号分支里 `LaneScroll()=0`)。验证:`gridanim` **93/93**(真实消息循环帧距 + 逐帧耗时对比 + 行为断言)、`verify` 49/49、`panelanim` **158/158**(按新规则更新 8 条断言)、`datacheck` 69/69、`edgecheck` 46/46、`errcheck` 19/19;`make release`/`make debug` 零警告。§11.2 新增第 125–133 条。
| 2026-10-17 | DeepSeek-V4.1-Flash(**本机**) | **滚动动画根治(巨型信号不再影响滚动)+「N 项」计数点击语义 + 浮动面板首现右上角 + 开关/关闭面板卡顿(详见 §9.44)**。① **滚动卡顿真因**:滚动到**从未建过图层**的区域时 `BlitWideBuffer()` 一个网格都贴不出像素 ⇒ 返回 `false` ⇒ 退化成**整幅重绘** ⇒ 每帧重画全部可见网格的波形(含千万级采样的巨型信号),实测 **20~35ms**(手动拖滚动条只要 ~6ms)。修法:只要还能画**网格底衬**(`backing` 非空)就返回 true ⇒ 动画均值 23→**11.2ms**、最坏 35.6→**13.1ms**,含 5 个巨型信号网格的帧 33→**~11ms**。② 新增 `Application::m_panelDirty`:面板动画帧只重绘面板+分隔条(−2.7ms/帧)。③ **「N 项」计数**独立为 `RowPart::GROUP_COUNT`(`GroupStatsRect` 为唯一几何来源),任何点击频率只折叠展开、绝不进改名;改名计时只对 `BODY` 记录。④ **浮动面板首现位置**改用「已显示面板序数」错开(旧实现用 `laneIndex`,上百网格时被夹到右下角),实测第一个面板 `topMargin=rightMargin=16*scale`(正上右角),6 个面板仍在顶部 22%。⑤ **开关/关闭面板卡顿真因**:`SetLane*` 末尾的 `RebuildWideBuffer()` **同步重渲染全部网格图层**(129 网格含巨型信号 ⇒ 几百毫秒);改用 `EnsureLaneLayersPrefetched()`(只在图层数与网格数不符时整批建立,其余登记待重画队列)⇒ 几百毫秒 → **≤22ms**。验证:新增 `.preflight/perf/lanescroll_main.cpp`(`lanescroll.exe`)**23/23**(逐帧耗时 + 巨型网格可见数关联、计数热区、面板位置、开着巨型信号的开关/关闭耗时);`verify` 49/49、`panelanim` 158/158、`datacheck` 69/69、`edgecheck` 46/46、`errcheck` 19/19、`gridanim` 93/93;`make release`/`make debug` 零警告。§11.2 新增第 134–140 条。 |
| 2026-10-17 | DeepSeek-V4.1-Flash(**本机**) | **多层加载拖拽卡顿根治 + 高亮命中线段中段 / 散点高亮 + 选区放大也高亮 + 自动点线判定与规则修正(详见 §9.45)**。① **拖拽卡顿(39.2→8.2ms 均值、47.9→10.3ms 最坏)**:**(a)** `RenderLaneLayerRegion` 每次补条都调两次 `BuildLaneVisuals()`(O(网格数))⇒ 253 网格时 `RollWideBuffer` 单帧 **26.8ms**;改为快照/拷回 `m_lanes`;**(b)** 横向“重新居中”纯属优化 ⇒ `kLayerRollQuantumX=32` + `kLayerRollsPerFrame=3`;**(c)** 位移过大时旧实现**同步整块重画**(巨型信号 20~130ms)⇒ `kLayerRollMaxShiftPixels=160` 作废图层交给底衬;`RollWideBuffer` 跳过离屏网格;**(d)** 包络带路径也支持补条裁剪(巨型信号一条 32px 竖带 5ms → 亚毫秒)。② **高亮命中**:`HitTestSignal` 横向窗口少算一个顶点 ⇒ 放大后两点**中间的连线**完全点不中(0/11 → 11/11);散点模式新增“数据点高亮环”(不含连线),列表/图例联动同源。③ **选区放大**恢复悬停高亮(仍不可拖信号),提示只显示信号名。④ **自动点线误判**:判定范围未限制在信号有数据的时段 ⇒ 长跨度文件 + 短信号时 MF4 信号默认视图全部误判(248→0);并改为**只对 `DrawMode::LINE` 生效**(SCATTER 不切换、用户手动改过不切换)。验证:新增 `.preflight/perf/hoverdrag_main.cpp` **20/20**;`verify` 49/49、`panelanim` 158/158、`datacheck` 69/69、`edgecheck` 46/46、`errcheck` 19/19、`gridanim` 93/93、`lanescroll` 23/23;`make release`/`make debug` 零警告。⚠️ 探针的绝对毫秒阈值改为**相对基准**(同一基线代码在不同时段可差 2~3 倍)。§11.2 新增第 141–150 条。 |
| 2026-10-19 | DeepSeek-V4.1-Flash(**本机**) | **修复列表内联编辑(组名/信号名)看不到插入符与选区 + `Utilities` 的 UI 基础层迁入 `Components/Shared`(详见 §12 末尾「2026-10-19 第二轮」)**。① **根因**:信号列表整行走**行位图缓存**(`SignalPanel::PaintRowCached`),而 `RowCacheKey` 只混合“是否编辑 + 编辑文本”,**没有插入符与选区** ⇒ 左右方向键、鼠标框选虽然改变了 `TextEdit` 状态,键却不变 ⇒ 贴回旧位图 ⇒ 用户看到“无法部分选择、光标不动”(绘图区光标时间输入没有行缓存,所以一直正常)。修法:编辑态把 `Caret/SelectionBegin/SelectionEnd` 混进键(分组行与信号行两处),并新增 `SignalPanel::EditFieldFont()`(分组名用 `uiSmall`、其余 `ui`)统一**绘制 / 命中 / 插入符换算**三处字体。② **目录归类**:`Theme.*` / `PaintUtil.*` / `TextEdit.*` / `PopupMenu.*` / `ColorPickerPopup.*` 由 `Utilities/{Inc,Src}` **`git mv`** 到 `Components/Shared/{Inc,Src}`(`Utilities` 只剩纯数据/逻辑;依赖方向仍 `Components → Shared → Utilities`);Makefile 无需改动,但要清掉 `Build/*` 里对应的旧 `.o/.d`。验证:新增离屏 `.preflight/editcheck`(`SW_HIDE` + 合成消息 + `BELOW_NORMAL`)**20/20**(A/B:修复前 6 项如实失败:键不变、屏幕不重绘);`make release`/`make debug` 零警告。 |
| 2026-10-19 | DeepSeek-V4.1-Flash(**本机**) | **组名/信号名编辑态点击穿透 + 浮动面板 GX 徽标悬停/点击联动 + 最小网格高度 1.5 倍(详见 §12 末尾「2026-10-19 第三轮」与 §11.2 第 151–153 条)**。① **点击穿透根因**:改名时行高大于输入框高度(上下各 3 逻辑像素),起点落在内边距上会继续走 `OnLButtonDown` 的行分支 ⇒ 分组展开/收起;且第二次快速按下被合成为 `WM_LBUTTONDBLCLK`,旧 `OnDoubleClick` 无编辑态保护 ⇒ 同样折叠/再次改名。修法:`NameEditAbsorbRect`(输入框横向范围 × 整行高度)在 `OnLButtonDown`/`OnEditMouseDown` 一律吸收为文本操作,`OnDoubleClick` 编辑态命中即返回。② **GX 徽标**:`EdgePanelLayout::badge` 成为唯一几何来源;悬停高亮 + 提示气泡(在面板缓存之后绘制,避免被烘进位图)+ 手型光标;点击 `ScrollLaneIntoView` 平滑滚动到该网格(已可见则不动)。③ **最小网格高度 54 → 81 逻辑像素**,并修掉 `m_minLaneHeight` 被二次乘 `m_scale` 的隐患(滚动条判定与实际高度改用同一换算)。验证:新增离屏 `.preflight/round3check` **31/31**(真实 MF4:128 网格、`height=101.25=81×1.25`);**A/B 禁用双击保护 ⇒ 1 项如实失败**;回归 `.preflight/editcheck` 20/20、`.preflight/check` 137/137;`make release`/`make debug` 零警告。 |
| 2026-10-19 | DeepSeek-V4.1-Flash(**本机**) | **`.mat` 流式解码(内存映射 + 逐 chunk 填充)+ MDF4 `##DL/##DZ/##HL` 数据块 + 光标数据点被时间面板遮住改三角(详见 §12 末尾「2026-10-19 第四轮」与 §11.2 第 154–159 条)**。① **`.mat`**:`MatHdf5::Open` 由“整文件 `fread` 进 `vector`”改为 **`MapViewOfFile`**(`MappedBytes` 只读视图,解析代码零改动);新增 `ReadDatasetInfo` + `StreamDataset(ByteSink)`(连续布局 4MB 切片、分块布局逐 chunk inflate)与 `ReadDoubleStream`(SDI `#data#` 字节流 → double,7 字节尾巴处理非 8 对齐 chunk);`ReadNumbers` 改为流式转换;`MatLoader` 先收集信号清单再按(分组, 时间流)合并成 `TableBlock`,时间列按内容去重、数据列 `std::move`,进度改成 `0–0.02 映射 / 0.02–0.98 逐流解码`。实测 `BIG_MAT.mat`(1.29GB / 5×16.14M):**117s → 12.5s**、私有内存 **+1234MB → +740MB**(数据本身 739MB)。② **MDF4**:`CollectDataSegments` 递归展开 `##DL`/`##HL`,识别 `##DT`/`##DV`/`##DZ`;`ReadRecordSegments` 支持跨段拼接与压缩段 inflate;压缩段记录长度改用通道位宽推算。`Problem-MF4.mf4` 由 **4 → 8 个信号**(与 Simulink SDI 一致)。③ **光标标记**:`MarkerCoveredByCursorLabel` + `CoverHiddenMarker`,圆点被底部时间读数/Δt 面板盖住时改为向下三角(原始信号与派生曲线两处)。验证:新增离屏 `.preflight/round4check` **34/34**;**A/B**(禁用 `##DL` ⇒ 2 项失败、禁用 `CoverHiddenMarker` ⇒ 3 项失败);回归 `round3check` 31/31、`editcheck` 20/20、`check` 137/137;`make release`/`make debug` 零警告。 |
| 2026-10-20 | DeepSeek-V4.1-Flash(**本机**) | **MF4 `##SD`/`##SI`/VLSD + 信号枚举文本 `enumText`(光标尾缀)+ `.mat` 由内存映射改为随机读(详见 §12 末尾「2026-10-20」)**。① **§2.6 两处**:`LoadPlainObject` 不再 `ReadDataset` 二次解码(改用 `ReadDatasetInfo` 取维度);`MdfFile::TextBlock` 真正支持 `##MD`(UTF-8 XML)。② **枚举**:新增 `SignalEnumText`(`values[]`/`texts[]`/`Find`)挂在 `DataTable::columnEnumText` 与 `SignalData::enumText`;MF4 `cc_type = 7` 按 `cc_val[i] ↔ cc_ref[4+i]` 解析。界面只在**光标取值标注**追加 ` = <枚举文本>`,常规状态零变化。`NEW-MF4.mf4` 实测 8 信号 / 4 条带枚举(32+5+32+5 项)。③ **MF4**:`##SI` 采样索引(≥2 条且全部 < 周期数才生效;实测工具会给每个通道挂只含常量 513 的 `##SI`,必须忽略)、`##SD` + VLSD(`UINT32` 长度前缀 + 64 位偏移,直接偏移优先、`>>1` 回退)、CN 链接下标按链接总数反推(4.0 的 8 链接 / 4.1 的 9 链接)、主通道接受 `sync_type` 1/2/3 + `cg_flags` 远程主通道、记录长度优先 `cg_data_bytes`、进度分母改真实 DG 数并上报字节、`##DZ` 28 字节头修正。④ **`.mat`**:删除 `MappedBytes`,改 `ReadFile` 随机读 + 16×64KB 元数据 LRU + 按需数据读取(固定 4MB 预读会把读盘量放大 2.2 倍 ⇒ 改后 1.0 倍);新增 rank=2 连续 chunk 快路径(12.9 亿次逐行迭代 ⇒ 每块一次回调),`BIG_MAT.mat` **21.5s → 7.9s**、私有 +741MB;进度按真实 `bytesRead / totalBytes` 推进(不再一上来 100%),取消会中止而不是留下 NaN 空洞。验证:新增离屏 `loadercheck` **32/32**、`enumui` **12/12**;回归 `datacheck` 69/69、`matcheck` 21/21、`matui` 15/15、`verify` 49/49;`make release`/`make debug` 零警告;`Target.exe` 785,920 → **820,224 字节**。 |
| 2026-10-21 | DeepSeek-V4.1-Flash(**本机**) | **MF4 数值换算类型 2/3/4/5/6 + `##CA` 数组通道完整支持 + 数值型 VLSD(详见 §12 末尾「2026-10-21」与 §11.2 第 160–164 条)**。① **换算**:`ChannelSpec` 把 `linearConversion/offset/factor` 换成 `conversion`(枚举 0–8)+ `conversionValues[]`,新增 `ApplyConversion`:`1` = `v0+v1*raw`、`2` = `(v0+v1*raw)/(v2+v3*raw)`、`3` = `(v0*raw²+v1*raw+v2)/(v3*raw²+v4*raw+v5)`、`4` = 交错 `(键,值)` 对线性插值(端点夹紧)、`5` = 同表阶梯取值、`6` = `(下限,上限,物理值)` 三元组区间映射;**参数个数不够或格式不符一律原样返回原始值,绝不猜**。② **`##CA` 数组**:CN 链接[1] → `ca_type(+0)/ca_storage(+1)/ndim(U16,+2)/flags(U32,+4)/dim_size[](U64,**+16 起、步长 8**,实测 Simulink 与 CANape 一致)`;`ExpandArrayChannel` 只在 `ca_storage == 0`(元素在记录内首尾相连)、`1 < 元素数 ≤ 1024`、`bit_count != 0` 时展开成多列,名字 `name[i]` / `name[i][j]`,复用同一换算与枚举。`MF4-Record.MF4` 信号数 **124 → 283**(9 点特性曲线 → 6000…14000;二维 map → 175/120/180…)。③ **VLSD**:数值型不再要求 `bit_count > 0`(新增 `InferVlsdElementBits` 按长度整除推 64/32/16/8),`DecodeVlsdValue` 只按一个元素做越界检查并取样本首个元素;主通道候选排除 VLSD。实测 4 个 MF4 文件的 VLSD 通道只有 `Comment`(字符串、`cycles = 0`),**数值型 VLSD 数量为 0** ⇒ 本次不改变现有输出。④ **单位**:4 个真实文件的 `cn_md_unit` **全部为空链接**;单位只出现在 `cn_md_comment` 的 `<TX>` 文本里(如 `(单位:MV )`、`(单位:0.1*N-700℃)`)与 CANape `<display>` 区间串里,故本轮不读单位。验证:新增离屏 `.preflight/convcheck`(**现场合成**一个 1 DG / 2 记录的 MDF4,覆盖 cc_type 1–6、`##CA`、数值 VLSD)**11/11**;**A/B 禁用新换算 + 数组展开 ⇒ 8 项如实失败**;回归 `check` 137/137、`editcheck` 20/20、`round3check` 31/31、`round4check` 34/34,真实文件信号数 283 / 8 / 18 / 24 不变;`make release`/`make debug` 零警告;`TargetCheck.exe` **804,352 字节**(785.5 KiB)。 |
| 2026-10-20 | DeepSeek-V4.1-Flash(**本机**) | **MF4 `##CA` 全字段(`ca_type`/`cycle_count`/`byte_offset_base`)+ 3 维命名 + 记录容量校验;`cc_type = 8` 区间枚举;稠密包络带的线宽改为「纵横双向加粗」(详见 §12 末尾「2026-10-20 第五轮」与 §11.2 第 165–170 条)**。① **`##CA`**:`ce_type`/`ca_storage`/`ndim`/`flags`/`ca_byte_offset_base`/`ca_inval_bit_pos`/`dim_size[]`/`cycle_count[]` 全部按**块长度做边界检查**后读取(`cycle_count[]` 实测缺失,盲读会拿到下一个块头 1481909027);展开条件从「数一个魔数上限」改为**记录容量校验** `bit_offset + 元素数×位宽 ≤ cg_data_bytes×8` + `cycle_count 每维 ≤ 1` + `ca_type ≤ 2`;规模护栏改为「元素数 ≤ 16384 且 元素数×记录数 ≤ 32M 单元格」;名字按 `dim_size[]` 逐维取余 ⇒ 1/2/3 维分别得到 `[i]`/`[i][j]`/`[i][j][k]`。② **`cc_type = 8`**(值区间→文本):`cc_val` 交错 `(下限,上限)`、`cc_ref[4+i]` 为第 i 段文本,`SignalEnumText` 新增 `rangeMin/rangeMax/rangeTexts` + `HasRanges()/Count()`,`Find` 先精确表后区间表;**仍然只在光标取值标注里追加 ` = <文本>`**。③ **线宽**:包络带旧实现只在每列**纵向**外扩 `strokeHalf` ⇒ 方波的竖直沿横向宽度始终是 1 个像素列(用户把线宽 1px 拖到 10px 时竖直沿完全不变);`EmitEnvelopeBand` 新增 `halfWidth` 形参,对上下包络做**横向窗口膨胀**(`top` 取窗口最小、`bottom` 取窗口最大)后再纵向各外扩,两个方向都随线宽变化。验证:`convcheck` **18/18**(新增 3 维命名、放不进记录的数组必须拒绝展开、区间枚举命中/未命中);新增 `.preflight/round5check` **5/5**(合成 20 万采样数字信号 ⇒ 包络带,中间行竖直沿墨迹 1px→6px、10px→30px);**A/B 关闭横向膨胀 ⇒ 1px→2px、10px→2px 并如实失败**;回归 `check` 137/137、`editcheck` 20/20、`round3check` 31/31、`round4check` 34/34,真实 MF4 信号数 283/8/18/24 不变;`make release`/`make debug` 零警告;`TargetCheck.exe` **808,448 字节**(789.5 KiB)。 |
| 2026-10-20 | DeepSeek-V4.1-Flash(**本机**) | **全项目大检查(性能 / BUG / 过期注释 / 错误命名)+ 修复(详见 §12 末尾「2026-10-20 第六轮」与 §11.2 第 171–180 条)**。① **数据层 BUG**:`MatH5` 的 `U8/U16/U32/U64` 用「逐字节 `Cached()`」拼装,而 `Cached` 只服务同一 64KB 块内的请求 ⇒ **跨块标量静默读成 0**(分块 B-tree 的 chunk 偏移/子节点地址被破坏,无任何报错);改为 `ScalarLE()`(命中缓存用缓存,否则一次 `ReadAt`)。`Mf4Loader::IsBlock` 未校验链接表是否落在块内 ⇒ `DataOffset` 越界后 `block_len - (data-offset)` **uint64 下溢**,下游 `vector(usable+1)` 直接 `bad_alloc`;`ReadAt`/`IsBlock` 的 `offset + bytes > size` 也回绕。非字节对齐的 **64 位整数**用「先拼 64 位再右移」会丢掉最高字节(32/8 位宽度组合受影响);`cg_cycle_count` 无上限(损坏文件可让程序申请几十 GB);`CsvLoader` 表头分支不跳过 `#`/`%` 注释行(整份文件解析失败);`MatLoader` 把文件里的 double 直接 `static_cast<int>`(NaN 越界 = UB)、普通对象递归无深度上限;`Utf8Util::BuildTicks` 的 `double→long long` 越界转换。② **UI BUG**:工具栏按钮**永远不显示悬停高亮**(`Paint` 读的 `Button::hover` 全仓从未赋值);菜单打开时**每个消息**都无条件取消底层拖拽 ⇒ 菜单自己的内联输入框无法拖拽框选(`Target::EDIT_FIELD` 被误清);`GdiplusShutdown` 前未释放 `PlotView` 的 GDI+ 位图(`Application` 是进程单例,退出时访问违例);`WM_KILLFOCUS` 不提交标签页/绘图区编辑;`WM_LBUTTONUP` 无条件 `ReleaseCapture` 抢走中键平移的捕获;`TextEdit` 字符被过滤器拒绝却返回 `true`、无选区时 `Ctrl+X` **清空整段文本**;`InvalidateRows()` 不作废 `m_editRow` ⇒ 行集合变化后回车把新名字写到**另一条**信号;`EndFilterEdit` 不调 `ApplyFilterText`(空格+回车后列表仍处于筛选态);`SignalBadgeVisible` 三处判据不一致(未绘制处仍可点出菜单);折起动画结束后不夹取滚动量(列表底部空白 + 滑块画出轨道);快速双击分组名改名后**折起动画仍在播**。③ **PlotView 缓存/正确性**:`FindStyleOnlyLanes` 只比 4 个外观字段,而 `SceneGeometryHash` 还排除了系数/镜像/偏移 ⇒ 两个网格同时改不同属性时有一个带着旧像素被当成“最新”;拖拽光晕被烘进宽基底图层(`LaneLayerHash` 不含它);`m_legendHits` 半可见时提前返回导致点中“看不见的图例”;面板主体 96% → **不透明**(消除平移后 4% 透出的旧波形);PAN / Shift+滚轮绕过 `SetTimeView`(自动点线不随平移重判);内联输入框命中用 `uiSmall` 而绘制用 `monoSmall`;派生曲线取值未按 `SourceIndexFor` 映射(水平镜像时读到镜像另一端);`EdgePanelWidth` 缺 `style/axis` 空指针守卫;`MinorCellSparse`/`AutoRangeLaneToView` 的二分结果未夹到 `count`(时间轴比 values 长时越界)。④ **清理**:删除死接口 `Toolbar::ActionLabel`、`Theme::statusBg`、`PopupMenu::editResult` 死机制、`Toolbar::Layout` 的重复 `clear()`、`HitRow` 的恒假分支、`OnDoubleClick` 的重复命中判定;修正一批过期/错误注释(`EmitEnvelopeBand` “不做加宽”、`LaneLayerHash` 的图例说法、`MeanFilterCurve` 的窗口说法、样式区行数与「调整」按钮顺序、`FIT_ALL` 退出缩放模式、`RemoveSignal` 的 ✕ 位置、`.mat`/`.dat` 格式文档、`MatH5` “映射视图”等)。⑤ **体积评估(未做修改)**:release **809,472 B**;`objdump` 分节 `.text 652KB / .xdata 43KB / .pdata 32KB / .rdata 42KB / .rsrc 20KB / .idata 11KB`;**去掉 `-ld3d11 -ldwmapi -ld3dcompiler` 实测 0 字节收益**(静态库只拉引用到的对象);仓内已无 `<iostream>/<sstream>/<filesystem>` 等体积炸弹。验证:全部离屏探针(`check`/`editcheck`/`round3check`/`round4check`/`round5check`/`convcheck`/`loadcheck`)与 4 个 MF4 + 3 个 `.mat`(含 1.29GB `BIG_MAT.mat`)回归通过;`make release`/`make debug` 零警告。 |
| 2026-10-20 | DeepSeek-V4.1-Flash(**本机**) | **新增实验性功能「自由标记」(网格内跟随鼠标的纵向光标 + 单击固定数据点 + 面板)**(详见 §12 末尾「2026-10-20 第七轮」与 §11.2 第 181–188 条)。① **数据层**:`Lane` 新增 `freeMarker` 开关、`FreeMarkerItem{ref, time}` 列表(`time` 存**显示时刻**,与光标同一口径)与 `freeMarkerPanel`;新增 `AddFreeMarker/RemoveFreeMarker/RemoveFreeMarkerAt/RemoveFreeMarkersOf/FindFreeMarker/HasFreeMarkers`(同一信号同一时刻不重复加入,容差 1e-12 相对量);`LaneCarry` 一并携带(重建网格不丢)。② **网格内交互**:`ProbeFreeMarker` 在「开启该功能的网格」内挑**离鼠标最近**的那个数据点(`MinorCellSparse` 成立时经 `SnapTimeToSample` 吸附到采样时刻并画圆环提示);`PaintFreeMarker` 画**仅限本网格**的纵向光标(上把手尖端朝下、下把手尖端朝上,**不显示时间读数**)+ 就近取值胶囊;已固定点画细线 + 圆点 + `值(±增量) = 枚举` 胶囊 + 行尾 ✕(悬停高亮 + 提示气泡)。**超出视界直接不显示**(不像常规数据点那样贴边定格 + 三角)。③ **单击 vs 框选**:`OnLButtonDown` 只登记 `m_freeMarkerPendingFix`,`OnLButtonUp` 里**原地未拖动**才真正 `AddFreeMarker`;选区放大模式下「按下后拖动」照旧进入框选并缩放。④ **面板**:新 `EdgePanelKind::MARK`(`PLOT_CMD_LANE_FREE_MARKER = 12`),按「网格内信号顺序 → 该信号内时间升序」展开;每行 `值(±增量) = 枚举` + 两列**右对齐**的 `t:<时刻>s` / `Δt:+<复合单位>`;信号名右侧 ✕ 清除该信号全部固定点、行尾 ✕ 移除单个;双击行编辑「自身时刻」或「相对前一固定点的间隔」(秒),提交后 `SortedFreeMarkerIndices` 自动重排(越过后续点即交换条目位置)。⑤ **两处修正**:`EdgePanelWidth` 的 MARK 分支**不做采样查询**(它被 `TopPanelAtPoint`/`SyncPanelOrder` 高频调用,逐行取值会拖慢鼠标),改用固定宽度 `kFreeMarkerPanelWidth` + 绘制侧省略号;`PaintFreeMarker` 与跟随光标共用 `PaintFreeMarkerCapsule`(避免两份重复绘制)。验证:新增离屏 `.preflight/markcheck` **31/31**(探测/就近选择/单击固定/三处✕/增量与时间列对齐/选区放大共存/吸附/越界不显示/间隔编辑后重排);回归 `check` 136/137(唯一失败项为既有不稳定项,基线同样失败)、`editcheck` 20/20、`round3check` 31/31、`round4check` 34/34、`round5check` 5/5、`convcheck` 18/18,真实 MF4 仍 283/8/18/24;`make release`/`make debug` 零警告;`TargetCheck.exe` **833,536 字节(814.0 KiB)** —— 比上一轮 **+24 KB**,**首次超出 800 KiB 的旧红线约 14 KB**。 |
| 2026-10-21 | DeepSeek-V4.1-Flash(**本机**) | **快进同步远端 5 个提交(`181f95c → 067128f`,35 文件 +3664/−313;未改动任何源码)**:`b315201` MF4 换算 2/3/4/5/6 + `##CA` + 数值 VLSD;`30f853a` `##CA` 全字段/3 维命名 + `cc_type = 8` 区间枚举 + 包络带线宽双向加粗;`53b981a` 全项目大检查(数据层/UI 真 BUG、死代码清理、体积评估);`dd8f63a` 新增实验性「自由标记」;`067128f` 自由标记改进(默认无光标 + 面板四列对齐 + 光标模式切换按钮)。本机验证:`make release TARGET_FILE_NAME=TargetCheck` **零警告**、产物 **857,600 字节**(GCC 16.1 seh 比起源设备 GCC 15.2 UCRT 略大);`.preflight/perf/datacheck` **69/69**,`MF4-Record.MF4` 仍 3 条时间轴 / **283** 信号(与远端记录一致)。 |
| 2026-10-21 | DeepSeek-V4.1-Flash(**本机**) | **「自由标记」体验重做 + 全部实验性面板可折起(详见 §12 末尾「2026-10-21 第二轮」与 §11.2 第 194–201 条)**。① **网格内固定点**:取值胶囊互相重叠时做**纵向避让**;✕ 改为**与胶囊同高的正方形**;**删除纵向半透明竖线**;新增「标记透明度」(0–100%,作用于本网格全部固定点:圆点 / 胶囊 / 文字 / ✕);⚠️ **GDI `DrawTextW` 忽略 alpha**,因此半透明时文字改走 **GDI+ `DrawString`**(不透明度 100% 时仍走 `UiText`,像素零变化),实测 0% 时胶囊区域与“无标记”帧逐像素相同(1433→**0**)。② **面板布局重做**:每个有固定点的信号一个**信号色托底分组栏**(三角 + 名称 + `N 标记点` + ✕,文字/三角/✕ 一律用 `UiContrastInk` 自适应对比色,点击分组栏收起该组);条目改成**五列**(取值 / 枚举 / 增量 / 时刻 / 间隔),首项增列与Δt列显示 `N/A`,**整个面板没有枚举文本时枚举列整列缺省**。③ **内容滚动**:内容超高时右侧出现滚动条(分组栏与条目一起滚动),滚轮在内容区翻页;底部按钮与透明度滑块**不参与滚动**。④ **面板折起**:`LanePanelState` 新增 `collapsed`,标题栏 GX 徽标左侧新增「三角 + 横线」指示器(`kEdgePanelDisclosureWidth`),单击折起/展开、双击标题栏切换;折起后只剩标题栏(五类面板全部支持)。验证:新增离屏 `.preflight/perf/mark2check.exe`(**69/69**,含 A/B:恢复竖线 ⇒ 1 项失败;禁用避让 ⇒ 2 项失败);回归 `datacheck` 69/69、`verify` 49/49、`panelanim` 158/158、`errcheck` 19/19;`make release`/`make debug` 零警告;`TargetCheck.exe` 866,816 字节。 |
| 2026-10-21 | DeepSeek-V4.1-Flash(**本机**) | **四处 BUG 修复 + 两处交互改进(详见 §12 末尾「2026-10-21 第三轮」与 §11.2 第 203–212 条)**。① **合并 → 拆分会把一个网格的实验性功能复制到每个拆分结果**(用户:第二个网格也自动启用了功能;「自由标记」还会在新网格里画出**别的信号的固定点**、而面板里查不到):`LaneCarry` 新增 `owner`(只有原网格**首个信号**继承功能状态),`SplitLane` 只让第一份继承;新增 `dmdetail::PruneLaneSignalItems` 在每次重建/拆分后按 `lane.signals` 过滤四类逐信号条目。② **阶跃信号的竖直沿比水平段粗**:`EmitEnvelopeBand` 的横向膨胀半径由 `ceil` 改 `floor`(1px 线宽不再膨胀 1 列)。③ **拖动浮动面板时下方另一个面板出现拖拽残影**:面板位置从 `PanelGlobalKey` 移除(曾让拖动任一面板都重画全部面板,而 MARK 面板的位置漏混 ⇒ 残影),并新增 `vacatedRects`(上一帧被移动/缩小/关闭面板腾出的矩形 → 与之相交的面板强制重画)。④ **大量固定点时拖全局光标/平移卡顿**:新增 `FreeMarkerContents` 内容缓存(取值 + 五列文本 + 信号色,键**不含视界**)。⑤ **面板外观/交互统一**:折起指示器改成与信号列表组名同款三角(去掉横线);GX 徽标与标题栏悬停**不改光标**、徽标气泡移除;标题栏**双击**滚动到该网格(单击不触发,折起只走三角)。⑥ **自由标记**:「标记透明度」滑块与光标模式解耦(始终显示);悬停胶囊恢复不透明(探针瞄着被遮住的数据点时除外);胶囊与面板五列**不再省略**(列宽按实测内容撑开)。验证:新增离屏 `.preflight/fixcheck` **37/37**(三处 A/B:恢复 `ceil` ⇒ 线宽断言失败;恢复 `owner`/`PruneLaneSignalItems` ⇒ 归属断言失败;禁用 `vacatedRects` ⇒ 残影 111px);回归 `round3check` 31/31(更新一条被用户推翻的旧断言)、`round4check` 34/34、`round5check` 5/5、`editcheck` 20/20、`convcheck` 18/18;`make release`/`make debug` 零警告;`TargetCheck.exe` **852,480 字节(832.5 KiB)**。 |
| 2026-10-22 | DeepSeek-V4.1-Flash(**本机**) | **「自由标记」面板表头 / 吸顶 / 行选中与悬停联动 + 固定点原位显示 + 探测容差与阶跃吸附(详见 §12 末尾「2026-10-22 第四轮」与 §11.2 第 213–220 条)**。① **列表头**:每个展开分组下方新增一行列标题(标记值 / 枚举文本 / 增量 / 时间点 / 时间间隔),与数据行共用列边界;列宽还需容纳列标题;内容总高与面板高度计入表头行;全部无枚举文本时整列缺省。② **吸顶**:分组栏与表头**各自**按 `y = max(自然, min(吸顶, 本组下边界 − 自身高度))` 钉住 ⇒ 组末尾按「表头先走 → 条目 → 分组栏最后离场」;绘制顺序改为「条目 → 表头 → 吸顶分组栏」,被盖住的条目不绘制也不可命中。③ **行交互**:`Lane::freeMarkerSelected`(单击选中/再点取消,`PanelSlotKey` 混入)+ 独立的整行悬停状态(整行悬停不会把 ✕ 变成 danger 色),悬停时网格内对应固定点整体高亮(数据点圆环 + 胶囊强制不透明 + accent 描边)。④ **固定点恢复原位显示**:删除 2026-10-21 的纵向避让,允许互相遮挡。⑤ **探测判据重写**:改为“鼠标到画出来的折线的垂直距离 ≤ `kSignalHitTolerance`”(与曲线高亮同源,复用 `AcquirePolyline` 顶点 + 点到线段距离),命中后吸附到最近采样时刻 ⇒ 修复 `SCATTER_TEST.csv` 阶跃竖直跳变线无法探测/固定的问题;删除 `kFreeMarkerHoverTolerance`。⑥ **滚动文字串到标题栏**:MARK 面板内全部文字改走 `UiTextClipped(..., content)`(GDI 文本不认 GDI+ 裁剪)。验证:新增离屏 `.preflight/mark4check` **38/38**(三组 A/B 均如实失败);回归 `markcheck` 61/61、`fixcheck` 37/37、`round3check` 31/31、`round4check` 34/34、`round5check` 5/5、`editcheck` 20/20、`convcheck` 18/18;`make release`/`make debug` 零警告;`TargetCheck.exe` **855,552 字节**。 |
| 2026-10-23 | DeepSeek-V4.1-Flash(**本机**) | **目录规范化(Inc/Src)+「自由标记」常规模式回归普通鼠标 + 两个显示开关 + 序号列/列分隔线 + 行悬停压暗(详见 §12 末尾「2026-10-23 第五轮」与 §11.2 第 221–229 条)**。① **目录规范化**:四个 `*Internal.hpp` 从 `Src` 迁到 `Inc`(`DataModelInternal` / `ApplicationInternal` / `PlotViewInternal` / `SignalPanelInternal`),并在 `Makefile` 固化「`Inc` 只放头文件、`Src` 只放 `.cpp`」;因 include 全是扁平形式,无需改任何 include。② **常规模式 = 普通鼠标**:`ProbeFreeMarker` 在未开启「自由标记点选取」时直接早退(不预览/不画纵向光标/单击不固定),并补上 `if (!hitCurve) continue;`(否则纵向光标整列都在时 `probe.ref` 恒有效 ⇒ 任意位置都能固定);固定点仍恒吸附到真实采样时刻,但“已吸附”圆环只在稀疏判据成立时画。③ **两个显示开关**:「自由标记点选取」下方新增「显示增量 | 显示枚举文本」(默认关闭,半宽同行),只影响已固定点胶囊,预览胶囊恒为“值 = 枚举”。④ **面板六列**:新增恒存在的「序号」列(组内 1 起),枚举列存在时无枚举/对不上的点显示 `N/A`,列间画竖线;悬停某行时其它固定点(数据点 + 胶囊)封顶 50% 不透明度、移出恢复。⑤ **移除固定点 ✕ 的悬停浮动提示**(`PaintFreeMarkerTooltip` 实现与声明全部删除);开启选取模式的网格上信号悬停提示只显示信号名。⑥ **两处缓存键补齐**:`FreeMarkerContents` 混入枚举表指纹(旧键会让加上枚举表后仍显示 `N/A`)、`PanelSlotKey` 混入两个新按钮矩形与开关状态。验证:新增离屏 `.preflight/mark5check` **52/52**(含“预览胶囊像素差 = 0”“行悬停压暗 848px → 移出恢复 0”“选取模式气泡变窄 1023→830”);回归 `markcheck` 67/67、`mark4check` 40/40、`fixcheck` 37/37、`round3check` 31/31、`round4check` 34/34、`round5check` 5/5、`editcheck` 20/20、`convcheck` 18/18、`diag` 19/19,`check` 136/137(唯一失败项为该探针既有的不稳定项,`drift=15818` 与改动前逐位相同);`make release`/`make debug` 零警告;`TargetCheck.exe` **857,600 字节(837.5 KiB)**。 |
| 2026-10-27 | DeepSeek-V4.1-Flash(**本机**) | **浮动面板事件穿透 + 「自由标记」总是预览最近信号 + 固定点位于 GX/图例之下 + 预览曲线取值插值 + 体积红线 900KB(详见 §12 末尾「2026-10-27 第六轮」与 §11.2 第 230–234 条)**。① **面板右键/中键穿透**:新增 `PlotView::OnRButtonDown`(`TopPanelAtPoint` 命中即置顶并消费,宿主不再弹网格 Y 轴菜单);`OnMButtonDown` 在面板上不再进入平移。② **自由标记探测去掉“必须贴近曲线”的门槛**:两个候选(折线垂直距离 / 同一时刻采样点纵向距离)取更近者 ⇒ 网格内只有一条信号、鼠标离曲线很远时也能预览并单击固定(A/B:恢复旧门限 ⇒ 5 项断言失败)。③ **固定标记点必须位于 GX 徽标与图例之下**:用 `gd::Region::Exclude` 把图例托底与徽标矩形从 `PaintFreeMarker` 的绘制区里排除,胶囊文字在相交时改走 GDI+(GDI 文字不认裁剪);实测图例区域像素差 **0**(旧的“画完再重绘图例”方案残留 240px 抗锯齿差)。④ **预览曲线取值点悬空**:`paintDerivedCapsule` 改为在光标时刻对派生曲线**按时间插值**(先 `ApplyDrawTransform` 再线性插值,与折线逐点一致);阶跃区实测标注点与曲线差 **0.0px**(旧实现偏 36–39px),中心轴/均值滤波/弱峰调整共用同一段代码。⑤ **体积红线由 800KB 放宽到 900KB**(到达阈值时提醒用户即可,不是禁止超过)。验证:离屏临时探针 **25/25**(已删除,不留在仓库);`make release`/`make debug` 零警告;`Target.exe` **860,672 字节(840.5 KiB)**。 |
| 2026-10-28 | DeepSeek-V4.1-Flash(**UI_Refactor 分支**) | **UI 重构第一阶段(P0/P1/P2,全部只提交到本地 `UI_Refactor` 分支,不影响 `main`)**:① 新增 `Components/Shared/{Inc,Src}/UiLayout.*`(矩形原语 / 对齐 / 设备像素内侧对齐 / Stack / Flow / 布局缓存)与 `UiWidget.*`(控件树基类:两段式布局、脏区下探绘制、只沿命中链派发、capture/modal/hover/focus 仲裁、光标认领链、统一动画 tick、内容指纹入口)。② 新增入库离屏回归网 `Tools/uicheck`(62 项断言,链接 `Build/<Config>/*.o` 排除 `Main.o`)。③ `PlotView` 实现按关注点重分为七个分片:`PlotViewPaint.cpp` 9 080 → **4 913** 行(−46%),新增 `PlotViewRaster.cpp`(1 876)、`PlotViewCacheKey.cpp`(474)、`PlotViewPanels.cpp`(1 359);搬移**逐像素 0 差异**(三场景渲染哈希 `A 2166933039626F66 / B C444ECFEB18FD73D / C 7984446D8E7A4418` 迁移前后完全一致)。详见 §11.2 第 235–240 条与 `Documents/UI_Refactor_Plan.md` / `UI_Refactor_Progress.md` / `UI_Refactor_Report.md`。 |
| 2026-10-28 | DeepSeek-V4.1-Flash(**UI_Refactor 分支**) | **UI 重构第五阶段:统一组件协议 + 去手写链路 + 大文件按职责拆分 + 代码审查 + `Tools/` 剥离(详见 §12 末尾「2026-10-28 第七轮」)**。① 新增 `Components/Shared/Inc/UiComponent.hpp`(纯接口、无状态):`SetScale / Layout / Area / Paint / HitTest / OnMouseLeave / IsEditing / QueryCursor`;`Toolbar` / `TabBar` / `SignalPanel` / `PlotView` 各自 `: public UiComponent`,**只加 `override` + 补 `Area()`,实现零改动**。② `Application` 新增 `ComponentEntry { UiComponent*; Target; bool chrome; }` 表 + `BuildComponentTable()`,把 `ApplyDpi` / `Relayout` / `WM_MOUSELEAVE` / `WM_SETCURSOR` 四条手写链改成循环;按下/松开/移动三条链**有意保留显式分支**(带组件专属后置动作)。③ `UiLayout::UiStackRects` 首次用于生产代码(内容区三列切分)。④ `SignalPanelInteraction.cpp` 2 329 → **831** 行(拆出 `SignalPanelEdit/Scroll/Menu.cpp`),`Shared/PopupMenu.cpp` 1 901 → **706** 行(拆出 `PopupMenuLayout.cpp` / `PopupMenuPaint.cpp` + 新增 `Inc/PopupMenuInternal.hpp`)。⑤ 代码审查修 5 处(过期头注释、重复构造函数、include 落进 namespace、缺 include、残留符号全仓零命中)。⑥ `Tools/` 从提交树剥离 + `.gitignore`,并对 `UI_Refactor` 做 `filter-branch` 清除历史。验证:`make release` 零错误零警告;`rendercheck` **8 场景哈希与基线逐一相同**、7 条 FUNC 全 PASS;`uicheck` **69 passed / 0 failed**;`TargetCheck.exe` **891,392 字节**。 |
| 2026-10-28 | DeepSeek-V4.1-Flash(**UI_Refactor 分支**) | **「显示布局边界」调试开关(Android 风格)+ 工具栏设置菜单(齿轮)+ 三处可见收益优化(详见 §12 末尾「2026-10-28 第八轮」)**。① **齿轮按钮**:`ToolbarAction::SETTINGS` 位于「导入文件」左侧(工具栏布局改为「左侧固定簇 `kLeftClusterCount=2` + 右侧组」),矢量化绘制(8 齿 + 外环 + 轴孔,按 `half` 比例推导;不用字体字符 `⚙`,因为不同回退字体大小/基线不一致)。② **设置菜单**:`MenuScope::APP_SETTINGS` + `HEADER "调试"` + 勾选切换项「显示布局边界」(`keepOpen`,点击后菜单保持打开、勾选态回写);锚点取 `Toolbar::ButtonRect(SETTINGS)` 的**控件实测矩形**。③ **布局边界**:`UiComponent::PaintDebugBounds(g,scale)` 虚函数(默认只画 `Area()`),`Toolbar`/`TabBar` 跳过根控件上报控件树子节点、`SignalPanel` 上报表头/筛选框/列表/样式区 + 可见行、`PlotView` 上报每个网格区域,宿主补客户区外框/分隔条/浮层;`UiWidget::PaintDebugBounds(g,scale,depth)` 递归整棵树。**只描边不填充**、线宽按设备像素固定、临时 `PixelOffsetModeNone` + 关抗锯齿 + 整像素对齐;层级配色 青蓝/品红/琥珀/绿。⚠️ 调试层**绝不进任何位图缓存**(只在 `Render` 最后一步叠加),且开启时 `fullFrame` 必须并入开关(否则只回送绘图区会让装饰区边界留在旧帧)。④ **可见收益优化**:`Toolbar::Layout` 控件树改按需重建(`m_treeDirty`,拖分隔条每帧不再重建);`SetActionLabel` 只写覆盖表(消除双写不同步);新增 `UiWidget::DispatchRedraw` 收敛 `Dispatch()+TakeDirty()` 重复写法。验证:新增离屏探针 `.preflight/lbcheck` **21/21**(含"关闭后逐像素还原""菜单锚定/勾选翻转");`rendercheck` **工具栏带以下区域哈希 8/8 与改造前逐一相同**(整帧哈希因新增按钮整体变化,属预期)+ FUNC 7/7;`uicheck` 69/69;`make release/debug` 零警告;`TargetCheck.exe` **897,024 字节(< 900KB)**。 |
| 2026-10-28 | DeepSeek-V4.1-Flash(**UI_Refactor 分支**) | **布局边界观感重做(四角粗/四边细)+ 全覆盖 + 几何同源(详见 §12 末尾「2026-10-28 第九轮」)**。① **描边规则改了**:四条边固定 **1 设备像素**,四个角加粗(层级 0 = 4px、其余 3px,沿边延伸 ≤14px 且 ≤ 短边一半),层级 > 0 整体**内缩 1px**(父子共边不再叠粗),**绝不填充**;整像素对齐 + `PixelOffsetModeNone` + 关抗锯齿。② **协议改为「收集 → 统一绘制」**:`UiComponent::CollectDebugBoxes(UiDebugBoxList&)`(默认只登记 `Area()`)+ `UiWidget::CollectDebugBoxes(boxes, depth)` 递归控件树;**不要**改成"所有元素继承同一基类"(会与 `PlotView` 4 层缓存 / `SignalPanel` 行位图缓存的失效体系打架)。③ **覆盖补齐**:`Toolbar` 按钮内容(新增 `UiButtonWidget::ContentRect()`)、`TabBar` ✕、`SignalPanel` 每个可见行的折起三角/复选框/色块/名称/GX 徽标/✕(组行另有组名/计数)+ 筛选框/清空✕/滚动轨道与滑块/样式 5 行与 12 色块/3 模式/3 调整/线宽与透明度轨道和数值/目标行/编辑框、`PlotView` GX 徽标/装饰层矩形/图例托底+色块+文本/**5 类浮动面板全部 20+ 字段**、`PopupMenu` 条目/可视区/轨道/滑块/二级子菜单、`ColorPickerPopup` 各控件。标签设**尺寸门槛**(< 56×16 逻辑像素不画)避免糊字。④ **性能**:`RowRect` 曾是 O(row) 逐行累加且画一行调 5 次 ⇒ 改用 `m_rowTop` 前缀和(`UpdateAnimGeometry()` 重建,长度不一致时退回累加)⇒ O(1);`ContentOffsetBeforeRow` 同步;`PanelBounds()` 收敛 6 处重复三元式。⑤ **齿轮按钮改主按钮配色**(与「导入文件」一致),图标重画(齿占最外一圈 0.66R→1.0R、外环 0.54R,否则齿被环描边吃掉只剩甜甜圈)。验证:`lbcheck` 21/21(含关闭后逐像素还原)、`rendercheck` 「工具栏带以下」区域哈希 **8/8 与上一轮逐一相同** + FUNC 7/7、`uicheck` 69/69、零警告、`TargetCheck.exe` **910,336 字节(889 KiB)**。 |
| 2026-10-28 | DeepSeek-V4.1-Flash(**UI_Refactor 分支**) | **布局边界自适应 + 绘制残留与层级缺陷修复(详见 §12 末尾「2026-10-28 第十轮」)**。① **描边按短边自适应**(取代第九轮固定包角):四边固定 1px;包角粗细 `min(4/3, 短边/10)` 夹到 `[1, 短边/4]`;包角长度 `min(14, 短边/3)`;**短边 ≤ 12 只画 L 形角标记不画边、≤ 5 完全不画**;内缩仅层级 > 0 且短边 ≥ 10。文字标签**整体删除**(含 `UiDebugPaintStyle`,省 ~1.5KB)。② **绘制残留三个成因**:(a) `PlotView::CollectDebugBoxes` 误读 `m_decorRects`(位图坐标系,负 Y)⇒ 横跨工具栏的幽灵巨框,改为只用 `LaneBadgeRect`/`m_legendHits`;(b) `Paint` 三条快路径只重画变化区域而调试线画在最后 ⇒ 新增 `PlotView::SetForceFullRepaint(bool)`,开启边界时禁用全部快路径;(c) 组件内元素越界(`lane.rect` Y 为负)⇒ box 增加 `clip`(宿主按 `Area()` 设置,`Add()` 跳过无交集、绘制时求交)。`Render` 的 `fullChrome` 并入 `m_showLayoutBounds`。③ **层级**:`UiDebugBoxList::PushLayer()` + 按 `(layer, depth)` 稳定排序;⚠️ 仅排序不够(调试线在所有内容之后),新增 `AddOccluder()` 让菜单/拾色器把本体登记为遮挡矩形,绘制更低层 box 时用 `Region::Exclude` 挖洞;⚠️ 浮层不要设 `clip=本体`(会把展开到本体外的二级子菜单整个裁掉)。④ **验证方法论**:判断残留不能用"拖拽帧 vs 松开后一帧"(图层预热进度不同会产生几十万像素合法差异),正确判据是**同一状态连续两帧逐像素相同**。新增探针 `.preflight/lbresid`(5/5)与 `.preflight/lbzorder`(4/4);`lbcheck` 21/21、`uicheck` 69/69、`rendercheck` 区域哈希 8/8 不变 + FUNC 7/7;零 warning;`TargetCheck.exe` **906,752 字节(885.5 KiB)**。 |
| 2026-10-28 | DeepSeek-V4.1-Flash(**UI_Refactor 分支**) | **边界从补丁改成机制:取消内缩 + 文本几何身份 + 补漏 + 提速(详见 §12 末尾「2026-10-28 第十一轮」)**。① **取消内缩**(第九轮为"父子共边不叠粗"加的 1px 内缩**已作废**):胶囊形元素(GX 徽标 30×17、圆角=高的一半)的可见边界就在外接矩形上,内缩会让圆弧留在框外;正确语义是"子框后画覆盖父框的边"。② **文本获得几何身份**:`PaintUtil` 新增 `UiTextRectSink` / `UiSetTextRectSink` / `UiSetTextRectOffset` / `UiGetTextRectSink`,在 `DrawUiText()`/`UiTextAtClipped()` 里按对齐方式 + 实测宽高把矩形收窄成墨迹矩形回报;`UiDebugBoxList` 直接继承它 ⇒ 65 处文本调用点一行都不用改。③ **缓存命中的行也要有文本框**:行位图渲染时用本地 `RowTextCollector` 收集(位图坐标)→ 存 `RowBitmapCache::textRects` → 贴图时按 `(originX,originY)` 回放;⚠️ **必须无条件收集**,否则"关调试时滚过列表 → 开调试"会命中缓存而永远无文本框。④ **补齐 PlotView 漏项**:`m_timeAxisRect` / `m_scrollBarRect` / `BackToTopRect()` / `m_cursorSpanRect` / `m_cursorLabelRects` / `EditFieldRect()`。⑤ **描边提速**:图元 12→8(4 边 + 4 自适应角块),`paintBoxes` **5.892→4.028ms**;⚠️ 图元数量是唯一瓶颈(`FillPath` ≈20µs/次、GDI `PolyPolygon` 5688 矩形 114ms,都更慢,别换)。验证:新增探针 `.preflight/lbcovr` **7/7**(时间轴/滚动条/回到最上方/GX 胶囊框重合/文本框/缓存行文本框/关闭清空);`lbcheck` 21/21、`lbresid` 5/5、`lbzorder` 4/4、`uicheck` 69/69;`rendercheck` 整帧与区域哈希与上一轮**逐一相同** + FUNC 7/7;零 warning;`TargetCheck.exe` **909,824 字节(888.5 KiB)**。⑥ 已知既有问题:`PlotView` 基线每帧在 `WarmLaneLayersStep()` 花约 2.4ms(与本功能无关)。 |
| 2026-10-29 | DeepSeek-V4.1-Flash(**本机**) | **远端同步 + 既有性能问题查证 + 浮层层级修复(详见 §12 末尾「2026-10-29 第十二轮」)**。⓪ **仓库**:远端 `gitea` 已把 `UI_Refactor` 合入 `main`(PR #1,`322b658`,分支保留);`main` 与 `UI_Refactor` 均同步到 `322b658`;**`dev` 已删除**(远端从未存在,意外产物)。① **上一轮"基线每帧多花 2.4ms"的结论是错的**:实测排空前 idle=2.60ms、**排空后稳态 0.53ms**、强制整帧 0.46ms ⇒ 那 2.4ms 是"载入 283 网格后给 16 个网格补建宽基底图层"的**瞬时**预热成本(每帧补 1 个,约 16 帧结束)。⚠️ 性能结论必须区分稳态与瞬时。② 仍修掉一处顺序问题:`RollWideBuffer` 原来对**全部** 283 网格先算 `LaneLayerKey()`(≈0.7ms/帧)再判可视性,且离屏网格会逐帧入队再被清掉;现在 `LaneVisuallyRelevant` 提到循环最前面。③ **浮层不被下层覆盖 = 三件事缺一不可**:`PushLayer()`+`(layer,depth)` 排序(谁压谁)、`AddOccluder()`+`Region::Exclude`(下层盒不进入浮层区域)、`SetClip()`(滚出可视区的条目不登记)。`PlotView::CollectDebugBoxes` 收集浮动面板前 PushLayer 并对每个面板 AddOccluder+SetClip;`PopupMenu::CollectDebugBoxes` 条目裁到 `m_scrollEnabled ? m_scrollViewport : m_rect`、子菜单条目裁到 `m_subRect` —— 修掉"网格边界穿透面板""滚动时面板/菜单边界闪烁或溢出"。④ `EdgePanelLayoutFor` 前置"该网格是否有面板展开"的廉价判据。验证:新增探针 `.preflight/lbpanel` **10/10**(面板遮挡 / 面板内容裁剪 / 滚动不闪烁 / 菜单条目裁剪 / 真滚动 1295px);`lbcovr` 7/7、`lbzorder` 4/4、`lbresid` 5/5、`lbcheck` 21/21、`uicheck` 69/69;`rendercheck` 8 场景哈希**与上一轮逐一相同** + FUNC 7/7;零 warning;`TargetCheck.exe` **909,824 字节(888.5 KiB)**。⚠️ 探针判"盒子越界"必须按绘制层过滤;构造「所属网格」菜单必须带分隔线+尾部固定项才会启用滚动区。 |
## 11. 跨设备协作须知(起源设备视角)
> **本文件是唯一权威记忆**。所有设备 / Agent 共用它,**不要再新建 `agent_mem_origin.md` 等旁支记忆文件**。
> §1–§10 = 另一台设备维护的**精简权威备忘**;§12 = 起源设备(本项目**从零构建**所在的那台机器,即本机)
> 的**详细工程记忆原文**(逐项功能与交互硬约束、UI 坐标、验证脚本、性能实测、Git/终端陷阱)。
> 三者同处一个文件,便于任何 Agent 一次读全。
### 11.1 两端工具链差异(重要)
| 维度 | 起源设备(本机) | 另一台设备 |
| --- | --- | --- |
| 编译器 | MinGW-w64 **GCC 15.2.0**(x86_64-win32-seh rev1,**UCRT**) | MinGW-Builds **16.1.0**(seh-rev0) |
| 运行时 / 内存 | UCRT;`std::thread` 实测**真并行**(2 线程 1.92x) | 见该设备记录 |
| 显示缩放 | **125%**(DPR 1.25,窗口边框偏移 `(9,38)`) | 见该设备记录 |
| Makefile | **相同**(`-lgdiplus -lcomdlg32 -ld3d11 -ldwmapi -ld3dcompiler`),两端 `make release` 均通过 | 同左 |
- 不要依赖"只在某一端成立"的编译假设;任何改动后至少在**本机**跑一次 `make release`。
### 11.2 高风险硬约束摘要(均来自 §12 实测;违反必出缺陷)
1. `PlotView::Paint()` 分支顺序**固定**:`LANE_REORDER` → 静态场景贴图 → 每网格图层(宽基底) → 样式补丁 → 整幅重绘。**勿改序**。
- 场景贴图必须在**图层之前**(否则鼠标悬停每两帧触发一次"结算整幅重绘",反而更卡);
- 图层必须在**样式补丁之前**(拖弱波增强 / 均值滤波参数只改派生曲线,走图层更快且质量更高)。
2. 每网格图层**只含基底**(底色 + 网格线 + 波形 + 派生曲线),**不含装饰**(Y 轴刻度 / 图例 —— 它们锚定网格左缘、不随时间视界移动)。
3. 图层位图宽度 = `width + 2*max(1, width/2)` ≈ **2 × 视界宽**(左右各半屏,可平移 ±半屏)。
4. `RenderLaneLayerRegion` 结束后**必须** `BuildLaneVisuals()`(重算 `m_laneContentHeight`)**再**还原 `LaneScroll` 与各网格 `yMin/yMax`;漏还原任何一项 → 算出的图层指纹与实际渲染不符 → 每帧重复重画(白耗性能;旧版有后台线程时会死循环卡死)。
5. ⚠️ **图层渲染默认在 UI 线程内同步完成**,但**多线程渲染「已获用户明确解禁」**(2026-10-13)。要点:
- 渲染函数(`RenderLaneLayerRegion` / `PaintLane`)会临时改写 `m_area`/`m_plotRect`/视界/网格矩形,**共享这些状态的并发渲染会互相覆盖**(视界弹回、命中错位)—— 这正是 2026-10-12 移除"后台图层线程"的根因。
- **允许并行的唯一安全姿势:每个工作线程持有"隔离的渲染上下文"**。已落地的实现见 `PlotView::RebuildLaneLayersParallel()`:为每条线程准备一个**隔离的 `PlotView`**(`m_tab` 指向它**自有的** `m_workerTab` 副本,`m_area`/`m_plotRect`/`m_lanes` 都是它自己的),各自把不同 `laneIndex` 的图层位图渲染到**不同的 `m_layerFront` 元素**(互不重叠),主线程 join 后按**真实网格下标**重算 `LaneLayerKey`。
- 实测:并行结果与顺序渲染**逐像素 0 差异**(5 网格全部 0)。⚠️ 但**本机 GDI+ 光栅化会被串行化**(4 线程 CPU 真并行 3.98x,而并行渲染 5 网格仅 **1.03x**)→ 光栅化并行**没有收益**,因此**不要把"逐帧预算 1 个网格"改成"一帧补齐全部"**(那只会把一帧从 ~10ms 抬到 ~31ms)。并行仅用于"本来就要一次画完"的 `RebuildWideBuffer()`。
6. 每个图层记录**自己的**时间映射 `LaneLayer::wideMin`;滚动与贴图必须**逐网格**按各自 `wideMin` 计算,**绝不共用一个全局锚点**(否则各网格被平移不同的真实距离 → 网格间时间错位)。补细条时必须传 `layer.wideMin`(不能用"当前视界"的映射,否则有 ≤0.5px 接缝与抖动)。
7. 加锁顺序**恒定** `m_renderMutex`(recursive)→ `m_layerMutex`(图层不再有跨线程访问,但顺序约定保留)。
8. ⚠️ **前台刷新线程绝不做“整批图层重绘”**:平移/拖动期间 `RollWideBuffer()` **只**做逐网格整数像素平移 + 补细条;内容过期 / 锚点过远的网格只登记到 `m_layerRebuildPending`,由 `RenderPendingLaneRebuilds(budget)` **本帧至多重画 1 个**,其余下帧继续(并 `PostMessage(kMessagePlotDerivedReady)` 请求重绘)。图层没覆盖到的部分 **`BlitWideBuffer` 只贴有像素的那一段**(其余留给底色,下帧补齐),**不得整块跳过**(跳过 = 网格变空 / “断线”)。
9. ⚠️ **“结算”(整幅全质量精确帧)只能在 `m_dragMode == NONE` 且全部图层就绪时发生**:否则按住键拖拽时会在“宽基底快路径”与“整幅重绘(拖拽时关 AA)”之间每两帧振荡 → **波形质量轮流变差 / 抖动 / 周期性卡顿**(用户报告的“按住拖拽时显示效果下降、出现断线,松手才恢复”)。
10. **GDI 绘制不受 GDI+ `SetClip` 约束**:`UiText`(`DrawTextW` / `ETO_CLIPPED`)与 `UiFillRect` 的 GDI 快路径(不透明且面积 ≥ 20000px²)都会画出裁剪区;宽矩形 / 文本必须**自己夹取**,GDI 批绘必须显式传 clip。
11. `SceneContentHash` 必须覆盖 `Tab().LaneScroll()`(网格纵向滚动量)与 `SelectedRef()`;`FindStyleOnlyLanes()` 必须**逐项比较 `SceneCacheKey`**(`SceneGeometryHash()` 刻意不含视界,只比它不足以判定"视图没变")。
12. 随鼠标变化的绘制(悬停高亮 / 光晕 / 图例高亮 / 光标读数 / 选区 / 提示 / 面板悬停)**必须同时**加在"缓存贴图快路径"与"整幅重绘路径"两侧。
13. 浮动面板必须按**层序**(`m_panelOrder`,队尾 = 最上层)绘制与命中:命中从后往前取第一个命中者,点击面板 `BringPanelToFront()`,新开面板追加到队尾。**不要再按 `kEdgePanelKinds` 固定顺序命中再 break**(否则上层 ✕ / 标题栏会被下层参数输入框抢走 → “上层面板关不掉 / 拖不动”)。
14. ⚠️ **多线程限制已解禁(2026-10-13),但必须"隔离状态"**:波形渲染可以多线程 —— 前提是每个线程用**自有的渲染上下文**(自有 `m_tab`/`m_area`/`m_plotRect`/`m_lanes`/DC),绝不共享可变渲染状态(见第 5 条的安全姿势)。已落地的并行只有:派生曲线线程池(`m_curveWorkers`)与并行网格图层渲染(`m_layerWorkers`)。⚠️ 实测**光栅化并行无收益**(GDI+ 串行化),所以并行只在"整批重建"这类本来就要一次画完的地方用。任何"移动 / 释放采样"的操作前必须 `PauseDerivedAsync()`。
15. MinGW 的 `swprintf` 按 C99:宽格式串里的 `%s` = **窄**字符串,宽字符串必须用 `%ls`(否则 "KB" 会被截成 "K")。
16. 源文件名必须**全局唯一**(Makefile 按 basename 去重 + vpath 查找);新增目录要同时登记进 `CXX_INCLUDES_PATHS` / `CXX_SOURCES_PATHS`。
17. ⚠️ **`m_legendHits` 只在"整幅重绘"路径开头被清空**,因此 `PaintLaneLegend` 在**任何**绘制路径
(**包括装饰缓存命中、提前 return 的那条**)都必须**先重建命中矩形**再决定是否跳过绘制 ——
否则清空后不再填充,图例的悬停高亮 / 点击选中 / 双击改名会**永久失效**
(用户报告"用中键平移过视界后图例点不动")。同理 `m_editFieldRect` 也必须在几何段设置。
18. ⚠️ **装饰缓存的像素必须与时间视界无关**:装饰位图的贴回边界由 `floor/ceil(rect)` 得出,
因此每块装饰(GX 徽标 / 图例托底 / Y 轴胶囊)都必须画成**不透明的圆角形状本身**
(`RenderDecorCache` + `AlphaBlend` 方案下圆角之外**保持全透明**,不要再用外扩的直角不透明底色)。
装饰矩形也必须**逐块**(禁止覆盖整条图例带)。验证方法:宽基底实时帧 vs 同视界从零重绘,
**装饰矩形内必须 0 差异**。
(历史:早期实现是 `CaptureDecorCache` 从后备缓冲区抓不透明矩形 —— 该函数**已删除**。)
19. ⚠️ **逐采样循环里禁止调用 `DataSet::Style(ref)` / `Resolve()` 等按下标解析的函数**:
`AutoRangeLaneToView` 曾因此把全段自适应从 147ms 劣化到 **1200ms(8 倍)**。
一律把指针提到循环外。整段(`begin==0 && end==count`)且无变换/无水平镜像时,
自适应应直接取 `SignalData::minValue/maxValue`(O(1),与原逐点扫描逐位一致)。
20. ⚠️ **"整批图层重建"绝不允许发生在交互帧里**:新增 `WarmLaneLayersStep()`,
在**命中静态场景缓存**(视界未变、非拖拽)时用既有预算机制**每帧至多补 1 个网格**并自请求下一帧。
否则"加载后的第一次平移 / 选区放大"那一帧会同步重建全部网格图层(首帧 64ms → 预热后 13ms)。
21. ⚠️ **派生信号必须"烘值 + 默认样式"**(推翻 §9.19 的"`offsetTime` 仍需继承",见 §9.21):
「调整」的**全部**项(含 `offsetTime`)都写进派生采样值,新信号样式一律默认;
否则「调整」面板会带着一整套继承来的指纹,或新信号与预览横向错位。
只有"无法烘值"的退化情形(时间轴非单调 / 时间列缺失)才退回把 `offsetTime` 带在样式上。
22. ⚠️ **一切"按屏幕上的值"做的统计/判定都必须用 `ApplyDrawTransform` 之后的绘制值**:
横向光标阈值、边沿计数(`EdgeCountsFor` / `EdgeCenterCountsFor`)、Y 轴自适应都属此类。
用原始采样值判定会得到"应用过「调整」的信号统计恒为 0"这类完全错误的结论。
这类**缓存**的键必须带**样式指纹**(`EdgeStyleKey()`),否则改完「调整」仍命中旧结果。
23. ⚠️ **限定统计区间时,"区间之外"的采样一律不参与**:只有**两个端点都在区间内**的采样对才计入。
不要在区间左端"多取一个采样点"来照顾跨边界的边沿 —— 那会把选区之外的那一段也算进来
(用户报告:"光标正好落在越过阈值的点 B 上时,B 被错认成上升沿")。
24. ⚠️ **图例托底 = GX 编号徽标同款**(同高 / 圆角 = 高度的一半 / 同配色 / 水平内边距 = 胶囊半径),
文本用更小的 **`uiTiny`(10 逻辑 px)**;`LegendHit.rect` 同时是命中矩形与悬停重绘的托底,
看到的必须严格等于点得到的。
25. ⚠️ **`PopupMenu::Open(..., expandUp = true)`** 表示"锚点是菜单**左下角**、向**上**展开"。
给按钮挂"二级面板"时用它(并把锚点设在按钮左上角再上移 `kAdjustMenuGap`);
`RebuildLayout()` **必须复用 `m_buildExpandUp`**,否则数值回写触发的重排会让面板跳回向下展开。
26. ⚠️ **标题栏 RAM = "私有工作集"**(`PROCESS_MEMORY_COUNTERS_EX2::PrivateWorkingSetSize`,
与任务管理器「内存」列同口径),**不是** `WorkingSetSize`(总工作集还包括映射的 DLL/共享页面与
已释放但未被回收的驻留页面,会明显偏大)。EX2 失败时退回 `PrivateUsage` → `WorkingSetSize`。
27. ⚠️ **千万级信号上任何"按阈值判定"的统计都不允许每帧重扫整段**:
`EdgeCountsFor` 已改为「逐块累积(`kEdgeBlockSamples`)+ 每 tick 预算推进(`kEdgeScanPairsPerTick`)」;
拖动横向光标(`EDGE_MOVE`)期间**不推进**(只返回上一个完整值 + `complete=false`),
界面用 `EdgeCountsText` 显示"统计中… / …"。实测:改前 25.8M 采样 ≈115ms/条/帧(5 条同网格 = 575ms/帧),
改后单次调用 ≤1.9ms、拖动中 10 次查询合计 0.11ms。
⚠️ 新增同类统计时**必须**沿用这个模型(先量化单次全扫代价,再决定分块与预算)。
28. ⚠️ **全仓没有 `try`/`catch`**:所有重分配失败抛 `std::bad_alloc`,而重计算都在 `std::thread`
(`CurveWorkerLoop` / `RunAsyncLoad` / 并行图层)里 —— 异常逸出线程入口 ⇒ `std::terminate()` ⇒ **进程终止**。
低内存下这是"崩溃"而不是"降级"。要改就从线程入口加 `catch` 开始。
29. ⚠️ **弱峰调整的工作缓冲 ≈ 34 B/采样(峰值)**,主导项是极值表(`PeakExtremum` = 24 B/极值,
编码器方波极值密集 ⇒ 接近 24 B/采样);而工作线程池最多 **4** 条并发 ⇒
25.8M 采样时单次 ≈0.9GB、峰值 ≈3.5GB(+数据 1.03GB +曲线缓存 ≤768MB ⇒ **4~5GB**)。
已测:3M 采样单次 ≈103MiB、4 并发 +412MiB。若日后要"限制峰值",优先:
①超大信号串行化 ②极值表字段瘦身/分块流式 ③废弃任务尽早退出 ④`GlobalMemoryStatusEx` 自适应降档。
30. ⚠️ **新建标签页的信号分组默认折起**(`NewTab(true)`;启动首个标签页 `NewTab(false)` 保持展开);
`SyncToDataSet` 用 `TabConfig::SetGroupsCollapsedByDefault` 的值初始化**新出现的**分组,
不得覆盖已存在分组的用户选择。
⚠️ 新增/修改 `NewTab` 调用点时别忘了带上这个形参。
31. ⚠️ **网格 GX 徽标悬停不改鼠标形状**(`QueryCursor` 在徽标上返回 `nullptr`,且**提前 return**
以免被光标线/图例判定抢走);只有拖拽中(`LANE_REORDER`)才是 `IDC_SIZENS`。
32. ⚠️ **预算推进型统计必须自己请求下一帧**:任何“分帧推进”的逻辑(边沿统计、图层重建)
在未完成时都要 `PostMessage(kMessagePlotDerivedReady)`,否则**用户不动鼠标就永远不推进**
(边沿计数面板“一直显示统计中、鼠标移上去才好”就是这个原因)。
33. ⚠️ **阈值/参数变化期间不要展示“部分值”**:部分计数每帧都被重置,显示出来就是
“错误且不更新的数字”。用 `justReset` 抑制一 tick,稳定后再展示递增的部分值(带 `…`)。
34. ⚠️ **`ScheduleCurveJob` 必须幂等**(`m_curveRunningKeys` + 队列去重):统计侧会每帧请求
同一份整条曲线;若每次都当作新请求,正在算的那份立刻被判过时 → **请求→作废→重请求 死循环**。
35. ⚠️ **大窗口派生计算不能无限并发**:单条整段曲线工作缓冲 ≈620MB(2580 万采样),
并发上限由内存压力决定(充足 2 / 紧张 1);内存**严重不足**时直接拒绝并只告警一次
(`kMessagePlotLowMemory`),绝不抛 `bad_alloc` 逸出线程。
36. ⚠️ **取消标志必须贯通到算法内部**(每阶 / 每 1M 采样检查一次),否则“过时任务立即清退”
只是口号:千万级一条曲线的工作缓冲要几百 MB,必须尽早归还。
37. ⚠️ **整段窗口不要复制采样**(`ComputeDerivedWindow`):8 B/采样 × 2580 万 ≈ 206 MB/条;
整段时直接引用原向量(结构变化前已有 `PauseDerivedAsync` 保证不被改)。
38. ⚠️ **离屏 harness / 探针必须在 `main` 开头把进程与线程优先级降到「低于正常」**
(`BELOW_NORMAL_PRIORITY_CLASS`),并保持“隐藏窗口 + 只注入合成消息 + 注入后立即 `ReleaseCapture`”。
`SetCapture` 是系统级的,不释放会把用户的真实鼠标输入吸进测试进程。
39. ⚠️ **参数变化类失效必须“定向”**:一律用 `InvalidateEdgeScansFor(signal, centreOnly)`
之类的按键接口,**禁止**对累积缓存(`m_edgeScans` 等)整体 `clear()`——
那会让所有信号、所有面板的统计一起重算(用户报告“改一个信号的参数,其它统计也重新计算了”)。
只有**数据层结构变化**(增删文件/信号、重排网格)才允许整体作废(走 `InvalidateDerivedCaches`)。
同理:**勾选/取消勾选**只改变“是否显示”,不应作废任何统计或曲线缓存。
40. ⚠️ **统计/判定所需的派生曲线不得依赖共享的“整条曲线”缓存**:
千万级信号一条整段曲线 ≈103MB,多条信号叠加会超出 768MB 预算 →
`PruneCurveEntriesLocked` 从队首淘汰(往往是**其它信号**的曲线)→
那些信号被判为“曲线未就绪”、显示“统计中…”并重新排队 → **偶发地看起来像“其它信号重新统计”**。
✓ 正确做法(`EdgeScanFor` 中心轴):**就地计算「当前块 ± 滤波半径」的一小段**
(半径 = `points * iterations`,窗口至少 8 个块,成本计入扫描预算,扫完即释放)。
41. ⚠️ **扫描预算绝不能因为在等某个条件就清零**:曾经“中心轴曲线未就绪 → `m_edgeScanBudget = 0`”
会把**同一帧里排在后面的其它信号的统计一起饿死**(测试里表现为全部卡在“统计中”)。
要“让路”就只跳过自己。
42. ⚠️ **累积状态上限必须大于「种类数 × 网格内信号数」**(`kEdgeScanStateLimit = 64`),
且超限时**优先淘汰「已扫完」的最旧状态**,绝不淘汰正在推进的。
上限太小会淘汰其它信号的已扫完状态 → 它们从头重扫(与第 39 条同一类症状)。
实测 10 个信号 × (普通+中心轴)= 20 个状态,原来的 16 就会踩到。
43. ⚠️ **“分帧推进”的预算必须按帧重置,禁止按 `GetTickCount64()` 分桶**:
系统时钟粒度约 15.6ms,而 `RequestEdgeScanFrame` 的 `PostMessage` 自持重绘会把帧率
拉到远高于它 ⇒ **同一 tick 内连画多帧但完全不推进**(用户报告“按住光标不动时统计
卡在统计中”“回车确认参数后不移动鼠标就停在部分值”)。
✓ 正确做法:`Paint` 开头 `++m_edgeScanFrame; m_edgeScanBudget = kEdgeScanPairsPerFrame;`。
44. ⚠️ **每个累积状态必须各自持有每帧配额**:共享一个总额 + “从列表第一个扫起”会让
多条信号里只有第一条有进展、其余长期显示“统计中…”(用户报告“很多项都停在统计中不更新”)。
⚠️ 分摊时**不能用 `m_edgeScans.size()` 当分母**:状态是**同一帧内逐个创建**的,
第一个状态那一刻 `size() == 1` 会独占整额,同帧后面新建的状态全部被截断为 0 配额
(表现为“首帧只有第一条信号推进”)。用 `share = max(kEdgeScanFairShare, size())`。
45. ⚠️ **「调整」类数值的符号规则**(用户明确要求,勿回退):
* **Y 轴偏移**:>0 带 `+`、<0 带 `-`、**0 不带符号**(旧实现恒带符号 → `+0.00`);
* **系数**:非 0 正值带 `+`、负值带 `-`、0 不带符号 —— 仅**非编辑态**;
进入编辑的初始文本用**不带符号**的纯数值;
* **时间轴偏移**:`Δt:` 后按正负带符号(`Δt:+5s 60ms` / `Δt:-5s 60ms` / `Δt:0s`),
统一走 `SignalPanel::FormatOffsetTimeText`(`FormatDurationCompound` 内部取绝对值)。
46. ⚠️ **浮动面板的信号名必须限宽**(`kEdgePanelNameMaxWidth = 150` 逻辑像素):宽度计算与
绘制都用 `min(实测, 上限)` + `UiEllipsize`;否则超长信号名会把面板顶得极长
(300 字符名实测多出 5100px,限宽后只多 44px)。
47. ⚠️ **边沿统计的“上升沿 / 下降沿”必须两列等宽排版**(列起点固定 → 多条信号垂直对齐):
面板宽度按 `2 × max(两列文本宽) + kEdgePanelStatColumnGap` 预留,绘制时两列各占
`(可用宽 − 间距) / 2`;⚠️ **绘制与宽度计算必须用同一套等宽假设**,否则较窄的列会被截断。
未完成时 **rising 与 falling 两个数字后面都要带 `…`**(旧实现只在整行末尾追加一次)。
48. ⚠️ **弱峰调整面板每行滑块右侧有复位控件**(与「系数/偏移」二级面板的还原图标同一套绘制):
点击把该行恢复默认 —— 幅度滑块 → `amountPercent = 0`;迭代滑块 → `iterations = 1`
**且 `maxIterations` 回到初始上限 10**。`EdgePanelLayout` 的 `rowLevelResets` /
`rowIterResets` **必须进 `PanelSlotKey`**(否则悬停高亮不会重绘);两行按钮共用同一 X。
⚠️ 滑块标签不得带多余的 `%` / `N`(但 MEAN / CENTER 参数输入框的 `迭代次数 N` 保持不变);
迭代次数上限输入框的空内容提示为 `输入上限(当前N)`。
49. ⚠️ **数据模型里禁止出现「文件」概念**(§9.27 重构后的硬约束):
分组 `SignalGroup` **不保存路径/文件名**(用户明确要求:加载后与源文件再无关系);
时间轴是独立的一等公民 `TimeAxis`,信号通过 `SignalData::axisIndex` 指向它;
引用用 `SignalRef::groupIndex`(不是 `fileIndex`)。
✓ 需要「时间 + 采样值」的函数一律只接 `const TimeAxis&` + `const SignalData&`,
**不要**再接 `const DataFile&`/`const SignalGroup&`(那是旧的过度耦合,已全部改掉)。
50. ⚠️ **新增文件格式只需加一个加载器**:实现 `Load*Source(path, LoadedSource&, error, progress)`,
在 `DataLoader.cpp` 按扩展名登记即可;数据模型(`BuildSignalGroup`)与全部界面代码**不得**因新格式而改动。
`LoadedSource` = 若干「时间轴块」(`TableBlock` = 一个时间列 + 同轴数据列),
时间轴去重、命名、取色、统计都在 `BuildSignalGroup` 里完成。
51. ⚠️ **CSV 多时间轴语义**:只有列名为 `t`/`time`/`timestamp`/`simulation time`/`时间`/`时间轴` 的列是时间轴
(判据**不得**放宽为「包含 time」,否则 `timeout` 之类会被误判并切出空块);每个时间列开启新块,
各块按**自己的时间列**裁剪 ⇒ 允许各轴长度不同。没有任何时间列时才回退「第一列是时间轴」。
52. ⚠️ **MF4(MDF v4)解析要点**:块头 24 字节 + N 个 8 字节文件偏移链接;
`DG[1] = CG`、`DG[2] = DT`、`CG[1] = CN`、`CG[2] = tx_name`、`CG data+8 = cycle_count`、
**记录长度 = DT 载荷 ÷ cycle_count**;`CN[2] = 名称`、`CN[4] = CC`,数据段
`+1 sync_type`(1 = 时间主通道)、`+2 data_type`、`+3 bit_offset`、`+4 byte_offset(字节)`、
`+8 bit_count`、`+12 flags`、`+16 invalid_bit_pos`;**绝对位偏移 = byte_offset*8 + bit_offset**;
`CC` 的 `+24 cc_val[]`,**换算 = `cc_val[0] + cc_val[1]*raw`**(用 CC 的 phy_range 自检)。
⚠️ **`##TX` 文本块必须兼容两种写法**:规范的「U32 长度 + UTF-16/UTF-8」**与** CANape 的
「无长度前缀的 NUL 结尾裸文本」;只按规范解析会得到空通道名。
压缩块(`##DZ`/`##HL`/`##SD`)、`##DL` 数据列表、字符串/MIME 通道**未实现**(跳过该组)。
53. ⚠️ **滚动 / 裁剪容器内的 GDI 文本必须显式裁剪,不能依赖 GDI+ 的 `SetClip`**:
`UiText` 走 `DrawTextW`,**GDI 文本不受 `Graphics::SetClip` 约束** —— 信号列表滚动时,
被滚出视口的「部分可见」行会把组名/信号名画到上方的筛选框上(用户报告 MF4 多信号场景)。
✓ 用新增的 `PaintUtil::UiTextClipped(g, text, font, rect, argb, align, clipRect)`
(同一个 HDC 上 `SaveDC` + `IntersectClipRect` + `RestoreDC`,其余行为与 `UiText` 完全一致);
行内**纯色填充**还要把矩形**夹到**容器矩形(`UiFillRect` 对大面积不透明矩形走 GDI `FillRect`,同样越界)。
54. ⚠️ **浮动面板像素图层的缓存键必须包含「面板上显示的动态内容」**:
边沿统计(上升沿/下降沿)是**分帧推进**的中间结果;`PanelSlotKey` 不含这些数字时,
「推进 → 数字变化」不会让键变化 ⇒ 面板一直贴旧像素 ⇒ 用户在**不移动鼠标**时看到统计「永不刷新」
(移动鼠标改变其它键后才显示正确值;注意扫描推进本身并未停止)。✓ `PanelSlotKey` 内按面板
**实际显示的条目**调用 `EdgeCountsFor` / `EdgeCenterCountsFor`,把 `(rising, falling, complete)`
mix 进键。
⚠️ 推论(写显示类测试时必须注意):**任何“内容随时间变化”的面板都要把该内容计入键**,
否则既会显示陈旧像素,也会掩盖“自持帧推进”的真实行为;反之,**为使显示类用例真正有判别力**,
测试数据要避开“其它变量恰好也在改变缓存键”的情形(如数字位数变化改变面板宽度)。
55. ⚠️ **与「网格集合」相关的自动行为必须同时挂在 `Layout()` 与 `RefreshLanes()` 两处**:
`Application::SynchronizeView` 是**按“网格数量是否变化”二选一**的 ——
变化 → `Relayout()` → `PlotView::Layout()`;不变 → `PlotView::RefreshLanes()`。
只挂一处必然漏掉一半场景(本轮:新勾选信号走 `Layout`、取消勾选走 `RefreshLanes`)。
⚠️ 这两处逻辑**绝不能放进 `BuildLaneVisuals()`** —— 它在 `Paint` 里也会被调用
(配合 `m_renderMutex`),在里面自动改样式 / 自动算 Y 会污染渲染路径。
56. ⚠️ **「自动行为」一旦要尊重用户的手动覆盖,其判定必须在恢复动作之前**:
本轮「自动点线」用**状态自证**(本机制只写 `LINE_SCATTER` ⇒ 自动期间 `mode != LINE_SCATTER`
就是用户改的)。⚠️ 用户改完样式可能**紧接着就退出阈值**(例如直接点适应视图),
而那次调用看到的**已经是新视界**;若把判定写在“退出分支”里,恢复动作会先覆盖用户的修改。
✓ 先无条件判定覆盖 → 再决定是否恢复。
57. ⚠️ **滚动容器的行几何只能有一个来源**:`SignalPanel::RowRect()` 是行内所有派生矩形
(名称框 / ✕ / 徽标)的唯一来源,因此「组名悬浮」只需给它加一个顶部坐标覆盖
(`m_rowYOverrideActive/m_rowYOverrideY`)即可整体跟随;并且**常规行循环必须跳过被钉住的那一行**,
否则同一行会被画两次(一次在滚动位置、一次在顶部)。
58. ⚠️ **光标吸附的判定只有一套定义(§9.30)**:某信号在当前一屏内的数据点个数
`< N × 4`(N = `GridMinorCellCount()`,网格一横排的小格数,与网格绘制同源)时
才为它开启吸附;判据**随视界实时重算**(疏密变化的信号自动在稀疏区吸附、稠密区自由)。
⚠️ 可见性与点数一律按**显示时间**(`采样时刻 + offsetTime`)判断
(`SignalSnapEnabled/HasVisibleSample/NearestSnapSample/LocalSampleSpacing` 都带 `timeShift`);
视界内至少要有**一个可见数据点**(否则不开启)。
⚠️ 判定**只针对鼠标所在网格**(`SnapTimeToSample` 用 `LaneAtY(m_mouseY)`);
⚠️ 吸附半径 = **一个网格小格宽**,且**不超过半个局部采样间距**(吸附 ≠ 卡住光标)。
⚠️ 旧的 `SampleSpacingPixels`(6px 下门限 + 半屏宽上门限)**已删除,不要再恢复**;
且吸附必须落在**画面上**的数据点(源时刻 + `offsetTime`),不能吸到历史位置。
⚠️「放大自动切点线」必须**与吸附判定完全同源**(`UpdateAutoScatterStyles` 调
`SignalSnapEnabled`),不要再引入“采样间距 > 半屏宽”之类的独立判据。
⚠️ `SignalData` 用 **`sparseTimes` / `sparseValues`**(有效点 ≤ 1024 时缓存全部有效点)
支撑吸附与单点初始视图;**不要**在鼠标移动时扫描整条采样数组。
⚠️ `LocalSampleSpacing` 对 `finiteCount <= 1` 必须返回 0(数据点间距无定义),
否则单点信号永远吸不住。
⚠️「放大自动切点线」必须**与吸附判定完全同源**(`UpdateAutoScatterStyles` 调
`SignalSnapEnabled`,同样传入 `offsetTime`),不要再引入“采样间距 > 半屏宽”之类的独立判据。
⚠️ 判定与查找都在**显示时间**空间:`NearestSnapSample` 内部按 `timeShift` 换算回源空间、
返回时再加回去,因此存在「调整 → 时间轴偏移」时也能吸到画面上的数据点。
59. ⚠️ **「2…16 个有效采样点用点线 + 2px」的需求已作废**(用户 2026-10-15 明确取消)。
仍保留:**只有一个有效采样点** → 「点线」+ **最大线宽**;其余信号保持默认「线段 + 1px」。
需要“提示数据点位置”的场景一律由 §9.30 的“放大自动点线”承担(同源判定)。
60. ⚠️ **自适应的范围与时机(用户 2026-10-15 澄清,这是“可见”要求的准确含义)**:
* **首个网格**显示内容时,要对它做完整自适应 —— **Y 轴与时间轴都**匹配自己的数据范围;
* **只有一个数据点**的信号必须显示在网格**中间**(否则时间轴覆盖整条时间列、
Y 区间退化成几乎为 0,数据点被挤到边缘甚至看不出位置)。
✓ 走 `FitSinglePointSignal()`(数据点居中、**时间跨度 1s**、**Y 轴跨度 1**);
⚠️ **工具栏「适应视图」也要走这条路**(仅一个网格 + 唯一可见信号为单点时),
否则用户点“适应视图”后数据点会“飞了”。
* **后续新增网格**只做 **Y 轴**自适应,**不再调整时间轴**(`ReapplyGlobalAutoFitIfEnabled`
挂在 `Layout`/`RefreshLanes`/`SplitLane`,这些路径都不碰 `Tab().ViewMin/ViewMax`)。
* 退化区间(恒定值 / 单点)的留白统一为 `max(1, |值|×0.1)`。
62. ⚠️ **幅值留白只能有一个来源**:`PadValueRange()`(`DataModel.hpp`,6%)。
整段自适应(`ComputeLaneRange`)与「选区 Y 轴自适应」(`AutoRangeLaneToView`)
**必须共用它** —— 两处各自硬编码(6% / 5%)时,「点过适应视图后再打开 Y 轴自适应」
会算出不同区间 ⇒ 网格被无意义地重绘一次(实测 ~14 万像素)。
反向(关掉自适应再点适应视图)同样会变。统一后这两种操作在视界未变时**像素级零变化**。
63. ⚠️ **组名悬浮的位置公式(勿改回“固定钉在列表上边界”)**:
`min(列表上边界, 本组最后一行下边界 − 组名高度)` ⇒
组名随最后一行**平滑滚出**(不突然消失),且下边界永不越过下一个分组行的自然位置
⇒ **不可能遮住下一个组名**;**折叠的分组不悬浮**(随滚动自然划过)。
行几何仍只由 `RowRect()` 提供(用 `m_rowYOverrideActive/m_rowYOverrideY` 覆盖顶部坐标)。
64. ⚠️ **图层(宽基底)里的任何刻度步长都必须由"前台视界"显式传入**:
渲染图层时 `RenderLaneLayerRegion` 会把 `Tab().ViewMin/ViewMax` 改成**宽窗口**(≈2× 前台),
因此 `PaintLane` **绝不能**拿它去 `NiceStep` —— 竖向必须优先用 `m_renderTimeStep`
(由 `RenderLaneLayerRegion` 用前台跨度算好传入),横向必须用 `m_wideTickSpan/m_wideTickHeight`。
违反时:图层里的辅助线步长 2×、位置与前台不同、"与主刻重合则跳过"的判据也错位
⇒ 拖拽期间网格线错位/整条丢失,且在图层帧与结算帧之间**闪烁**(实测 49px vs 25px、20 vs 40 条)。
65. ⚠️ **拖动"非绘图区组件"(信号面板滚动条等)时,鼠标移动不得只失效绘图区**:
`plotOnlyMove` 必须先排除 `draggingNonPlot = (m_activeTarget != NONE) && != PLOT && != EDIT_FIELD`。
否则鼠标一进绘图区就只重绘绘图区,而组件自身的滚动偏移已经变了 ⇒ 滑块/滚动条"卡在原地不动"。
66. ⚠️ **工具栏必须把 `WM_LBUTTONDBLCLK` 当作"按下"处理**(窗口类带 `CS_DBLCLKS`:快速连点时第 2 次及以后
的按下会被合成成它,不处理就**完全丢失那一次点击**);并且**按住期间不得**因 `OnMouseLeave()` 清掉按下态
(`m_pressedIndex`)—— 否则"按住按钮划出边缘再回来松手"不会触发任何动作。
67. ⚠️ **单点信号(`finiteCount == 1`)必须在命中测试 / 光晕 / 拖拽虚影里单独处理**:
它没有折线可测距,`HitTestSignal` 要用 `SinglePointScreenPos()` + 容差 `max(常规容差, 点绘制半径)`;
悬停时**不改鼠标形状**(`QueryCursor` 不查 `HitTestSignal`),只在按下拖拽后改(`IDC_SIZEALL`)。
68. ~~⚠️ **多网格单点信号的「适应视图」规则**(用户 2026-10-15 明确给出):左/右边界 = 所有网格中
最左/最右数据点时间再向外偏**一个网格小格**的时间跨度;每个网格的 Y 按自身数据点自适应,
若该网格内存在"时间一致但取值不一致"的多个单点 ⇒ 纵向上下各留**一个 Y 小格**;
全部点在同一时刻 ⇒ 时间跨度 **1s** 居中于该时刻(与"首个单点信号初始视图"对齐);
某网格只有一个取值 ⇒ 该网格 Y 跨度 **1** 居中。仅在**所有可见信号都是单点**时启用。~~
⚠️ **本规则已作废(§9.33,用户 2026-10-15 第五轮明确要求撤回重做)**:
改为“边界处的单值散点让出**该散点的半径**”(而不是一小格),且不再要求“全部都是单点”
才生效 —— 任何一条边界被单值散点顶住都要让位。现行规则见 69–71。
69. ⚠️ **「适应视图」的边界规则(§9.33,用户 2026-10-15 第五轮重新定义)**:
常规全局适应后,**若某条边界(时间轴左 / 右、每个网格幅值上 / 下)被一个单值信号的
数据点顶住,则把该边界向外让出该散点的半径(+1px)**,使其完整显示在网格内;
**只在散点会被裁切时才扩展**(内部散点不动视界);边界不是单值散点时边界就落在该
信号点上(时间轴不留余量;幅值仍走 `PadValueRange` 的 6%,**没有**改动它)。
判断依据必须是**信号自身的数据点**,不能用“轴的范围”——MF4 里单值信号与连续信号
常共用同一通道组(同一条轴),用轴判断会误判。
70. ⚠️ **特例:全部可见数据点都是单值信号且时刻完全相同**(`FitSinglePointCluster`):
时间轴**以该时刻居中**、缩放到 **一大格 = 1s**(视界 = `kTimeTickTarget × 1s` = 10s,
此时 `NiceStep(10,10) == 1.0`);**幅值轴与其余场景完全同源**(见第 76 条):
`fitYToSelection` 的网格走 `AutoRangeLaneToView`、其余走整段自适应(`ComputeLaneRange`),
两者都只由 `PadValueRange` 提供留白 —— 退化(单值)时的区间就是
`[v − max(1,|v|×0.1), v + max(1,|v|×0.1)]`(跨度 = 2×留白)。
⚠️ 旧版曾在这里用"半径留白 + **跨度 1**"另算一套,已作废(会导致
「适应视图」与「Y 轴自适应」对同一视界算出**不同区间**)。
旧规则"跨度 1s 居中""各留一小格"同样已作废。
76. ⚠️ **`FitAll()` 的两条路径(常规 / 单点簇)必须共用同一段逐网格幅值逻辑**:
`fitYToSelection` 的网格 → `AutoRangeLaneToView()`;否则 → `autoRange = true`;
两者之后都接 `ApplySinglePointValueMargins()`。**不得**任何一条路径里另写一套区间
(否则「适应视图」与「Y 轴自适应」会互相改变对方的结果)。
⚠️ 且 **`FitAll` 绝不可写 `lane.fitYToSelection`**:用户开着「Y 轴自适应」时点一下
「适应视图」会把它悄悄关掉(用户 2026-10-16 报告)。
77. ⚠️ **信号面板「分组折起/展开」必须是「**抽拉卷尺**」式动画**(用户 2026-10-16 第二轮,
推翻第一轮的“按行缩放”):整组信号作为一个**刚性整体**平移 —— 收起时所有信号一起向上移动、
逐条消失(被组名栏吞掉),展开时逐条从组名栏下方冒出;**卷尺上的元素相对位置不变**
⇒ **行高绝不改变**,只维护一个逐行平移量 `m_rowShift`(`UpdateAnimGeometry`)。
收起:`shift = fromShift + (blockHeight − fromShift) × easeOutCubic(p)`;展开:推到 0;
时长 = `clamp(115 + 6×行数, 150, 320)` ms。
⚠️ **动画期间不得重建行集合**(`AdvanceGroupAnimations` 不能设 `m_rowsDirty`)——
这正是该方案流畅的关键(探针直接断言中间帧 `m_rowsDirty == false`)。
⚠️ 中途反向点击(折叠没播完就点展开)必须**从当前位移续接**(`GroupAnim::fromShift`),不许跳变。
78. ⚠️ **平移只作用于“组名行之后”的行,并且必须双向裁剪**:组名是“卷尺外壳”始终不动。
信号行越过**组名绘制位置的下边界**就要被裁掉(`AnimationHideTopForRow` + `ClampRowClipTop`),
已经完全越过的行整行跳过。
⚠️⚠️ 除了 GDI 裁剪(`PaintRowText`/`FillRowRect` 用的 `m_rowClipRect`),**必须同时用
`g.SetClip(clip, CombineModeIntersect)` 收紧 GDI+ 裁剪** —— 行内的色块 / 复选框 / 网格徽标
走 GDI+,只受 `SetClip` 约束,不收紧就会画到组名栏上面(A/B 实测会失败)。
(探针逐帧断言“组名栏区域像素零变化”。)
79. ⚠️ **筛选模式的分组折起状态与普通模式**分开**保存**(`m_filterCollapsed`,默认展开):
筛选只是临时子集视图,不该污染普通列表的布局选择;筛选文本变化 / 清空筛选 / 数据变化时
一律清空它以及进行中的折起动画(行数与分组可见性都变了)。
组名后方的项数必须按当前模式显示:普通 = 信号总数,筛选 = 该组内**符合筛选条件**的个数
(`ListRow::displayCount`,在 `BuildRows` 里填好;`PaintGroupRow` 只读它)。
⚠️ 推论:**所有“读取折起状态”的地方都必须用 `IsGroupCollapsed()`(按模式分派)**,
不能直接读 `m_tab->IsCollapsed()` —— 否则筛选态折叠时**折叠三角不会变**
(用户 2026-10-16 报告;已用“筛选态 vs 普通态折叠后组名行逐像素 0 差异”锁定)。
80. ⚠️ **悬浮(sticky)组名的命中判定必须最优先**:`HitRow` 要先用
`StickyGroupRowIndex()` + `StickyGroupRowTop()` 判是否落在悬浮行矩形内,命中则临时打开
`m_rowYOverrideActive` 并用 `RowHitPart()` 算行内区域 —— 否则点击会**穿透到它下面的那一行**
(用户 2026-10-16 报告:悬浮组名的折叠三角/复选框/✕/名称全都点不动)。
⚠️ `HitRow` 必须**逐行比较实际绘制位置**(`m_rowShift` 参与),不能用“滚动偏移→内容坐标”
的一刀切映射;并且要跳过完全藏进组名栏的行(否则空行会白白吸走一次点击)。
81. ⚠️ **悬浮组名折起:一边向上滚动一边收起**(用户 2026-10-16 第二轮,**推翻** §9.35 三的
“先滚动到位再折起”):`GroupAnim::scrollSynced` 让滚动偏移与平移量按**同一进度**推进
(`m_scrollOffset = scrollFrom + (scrollTo − scrollFrom) × eased`),
从而**组名的屏幕位置恒定**(悬浮组名始终在最上方)。
目标 `scrollTo = min(组名的内容偏移, 折起后可达的最大滚动量)`(后者处理“折起后内容比列表短”)。
⚠️ `m_pendingGroupToggle` 两段式机制**已删除,不要再恢复**;
用户滚轮 / 拖滚动条 ⇒ `CancelScrollAnimation()` **只解除同步**,折叠/展开继续播放。
82. ⚠️ **`PaintRowText` 只能用于“列表行”**(它把文本裁到 `m_listRect`,见第 53 条)。
样式区(如「目标」行)里的文本**必须用不裁剪的 `UiText`** —— 否则文本会被完全裁掉
(用户 2026-10-16 报告:“目标”行的信号名与 GX 标签都没有正确绘制;A/B 实测该区域文本像素为 0)。
83. ⚠️ **样式区「目标」行的布局**(用户 2026-10-16 要求):名称从**内容列左边缘**开始、
GX 标签**右对齐到内容列右边缘**(与线宽滑轨/数值框同列宽同两端),两者不得重叠;
**无 GX 标签时名称可用宽度 = 整个内容列**(因此更宽,仍左对齐)。
⚠️ `EditFieldRect()` 对 `m_editRow < 0` 必须返回 `m_targetNameRect`(**只管名称那一块**),
不能返回内容列全宽 —— 否则改名输入框会把 GX 标签包进去(用户报告)。
84. ⚠️ **筛选态的折叠三角必须用 `IsGroupCollapsed()`**(见第 79 条推论):`PaintGroupRow` 直接读
`m_tab->IsCollapsed()` 时,筛选模式里折叠/展开三角**不会变化**(用户 2026-10-16 报告)。
85. ⚠️ **分组折起的热区只有两处**(用户 2026-10-16 第三轮要求):**折叠三角**(`RowPart::DISCLOSURE`)
与**「组X」标签**(`RowPart::GROUP_TITLE`)。点击分组**名称本体**(`BODY`)**不得折起**
(也不要触发滚动)。`RowHitPart()` 必须为分组行判出 `GROUP_TITLE`(标签矩形从
`checkRect.Right + 8*scale` 起、宽 `GroupLabelWidth(row)`);`OnLButtonDown` 只在
这两个区域调用 `ToggleGroupFromRow()`。
86. ⚠️ **分组改名输入框不得覆盖右侧的「N 项」计数**(用户 2026-10-16 报告):
`RowNameRect()` 对分组行的右边界 = `项数左边缘 − 6*scale`。
✓ 项数文本与它的宽度必须走**同一个来源** `GroupStatsText(row)`(`GroupLabelText(groupIndex)`
同理),否则“名称可用宽度”与“实际绘制文本”会漂移。
87. ⚠️ **`SignalData` / `SignalGroup` 只有一个名称字段 `name`**(用户 2026-10-16 明确要求):
它既是加载时的列名 / 通道名,也是用户重命名写入的地方;**不要再引入 `displayName`
或 `Title()` 之类的第二份副本**(信号靠 `SignalRef` 下标定位,从不按名字匹配)。
`RenameSignal(group, signal, name)` / `RenameGroup(group, name)` 对**空串 / 纯空白一律拒绝**
(名称不再有“回退值”)。
88. ⚠️ **通用 UI 控件属于 `Components/Shared`**(用户 2026-10-16 要求建此组件):
图标 / 复选框 / 滑块 / 内联输入框 / 颜色样块 / 取值胶囊配色都在
`Components/Shared/Inc/UiControls.hpp`。
⚠️ **【2026-10-19 更新:UI 基础层整体迁入 `Shared`】**用户指出 `Utilities` 里那些“会绘制/承载
UI”的模块不属于纯粹工具范畴,现全部位于 **`Components/Shared/{Inc,Src}`**:
`Theme.*`(调色板与 `ToGdiColor/MakeArgb/WithAlpha/MixColor`)、`PaintUtil.*`(低层绘制原语)、
`TextEdit.*`(内联编辑模型)、`PopupMenu.*`、`ColorPickerPopup.*`。
⇒ **`Utilities/` 只剩纯数据/逻辑模块**(`DataModel`、`DataTable`、`CsvLoader`、`DataLoader`、
`Mf4Loader`、`MatH5`、`MatLoader`、`Utf8Util`),**不得**再往 `Utilities` 放任何包含
`gdiplus.h` 的绘制/控件代码。
依赖方向仍是单向:**`Components/* → Components/Shared → Utilities`**(`Shared` 可以消费
`Utilities` 的 `Utf8Util` 等,反向禁止)。搬移时**只需 `git mv`**:`#include "X.hpp"` 是平铺写法,
两个目录都在 `CXX_INCLUDES_PATHS` 里,**Makefile 无需改动**(但要清掉 `Build/*` 里那几个
`Theme.o/PaintUtil.o/TextEdit.o/PopupMenu.o/ColorPickerPopup.o` 及其 `.d`,否则旧依赖文件指向旧路径)。
新增组件目录要同时登记 `Makefile`(`CXX_INCLUDES_PATHS` / `CXX_SOURCES_PATHS`)与
`.preflight/perf/build.ps1`。
89. ⚠️⚠️ **新增标识符前必须确认不是 Windows 头里的宏**:`windows.h` 会引入 `nb30.h` 的
`#define GROUP_NAME 0x80` ⇒ 用它做枚举成员会编译失败(“expected identifier before numeric
constant”)。同类高风险名:`GROUP_NAME`、`TIME`、`SMALL`、`NEAR`、`FAR`、`IGNORE`、`DELETE`。
90. ⚠️ **二进制体积的真正大头是 ICO 里未压缩的 256×256 条目**(本轮实测占整包 27%):
应把它按 **PNG** 存储(Vista+ 标准,观感零变化),小尺寸保留 BMP。
⚠️ **拆分大文件不会减小机器码**;“拆分 ⇒ 瘦身”不成立。
⚠️ `-ffunction-sections/-fdata-sections/--gc-sections` 在本工程**反而增大 9 KB**(已回退)。
⚠️ `.o` 字节数受节对齐/重定位表主导,**判断“代码搬去哪了”要用 `nm`,不要看 `.o` 大小**。
91. ⚠️⚠️ **大文件多文件化的唯一安全手法**(本轮已对 `PlotView`/`SignalPanel`/`Application`/`DataModel`
四个文件执行):
1. 把原文件的**匿名命名空间**(PlotView 约 870 行)整块搬进 `<组件>Internal.hpp`,
从 `namespace { }` 改成**具名**命名空间(`pvdetail`/`spdetail`/`appdetail`/`dmdetail`),
并把其中**每个函数与常量都标 `inline`**;
2. 头部末尾加 `using namespace <ns>;` ⇒ 各 .cpp 里原有的非限定调用**一行都不用改**;
3. 成员定义按「方法名 → 分片」归类后**整块搬移**(纯代码搬移,不改函数体)。
⚠️ 易错点:
* `[[maybe_unused]]` 等属性**必须排在 `inline` 之前**(`inline [[x]] T` 非法);
单独的 `{`/`}` 行、注释行、`struct`/`enum`/`using`/`template` 行**不能**加 `inline`;
* 匿名命名空间的**结束行可能带注释**(`} // namespace`),匹配时不能只认 `^\t}$`;
* **首个成员定义之前的所有行**必须跟着进核心文件(`DataModel.cpp` 有约 390 行命名空间外的
自由函数),否则会被静默丢弃;
* 内部头要带上原文件真正用到的全部头(`DataModel` 需 `Theme.hpp`/`<cstring>`/`<cstdio>`/
`<deque>`/`<atomic>`/`<cwctype>`/`<new>`)。
92. ⚠️ **多文件化会略增产物**:`inline` 工具函数会在每个引用它的 TU 各生成一份(PE 无 ICF 合并),
实测 `PlotView` 拆分 +5 120 B、四个文件全部拆完 +10 240 B(共 +1.4%)。
这是**可维护性换体积**的有意取舍;若日后必须压体积,优先考虑把工具函数收敛到单个 TU
(用 `extern` 声明 + 单一实现)而不是撤销拆分。
93. ⚠️⚠️ **批量改名脚本不能同时覆盖「声明」与「使用」**(`LanePanelState` 抽取时踩到):
把 `edgePanelOpen → edgePanel.open` 这类「聚合化」替换对同时用在 `Lane`(使用处)和
`RebuildLanes` 内部的 `LaneCarry`(**声明处**)上,会生成
`bool edgePanel.open = true;` / `double edgePanel.x = -1.0;` 这类非法声明
(报错为 `expected ';' at end of member declaration`)。
正确做法:**声明处单独重写**(改为 4 个聚合成员),只让「使用处」走替换对;
同一个替换对**不要**在同一个文件上跑第二遍(会二次展开,例如 `a.b` → `a.b.b`)。
94. ⚠️⚠️ **任何「宿主级反馈通道」都必须做像素级验证**(本轮实测):
`PlotView::Paint` 在 `m_lanes.empty()` 时**提前 return**,而提示浮层在 return 之后的叠加层绘制 ⇒
**一个信号都没勾选时任何非模态错误都不可见**(恰恰是「文件加载失败」最需要提示的场景)。
「通道存在」(`m_hintText` 被赋值、`HintNeedsAnimation()` 为真)**不等于「通道可见」**:
必须抓后备缓冲、比较「有提示 / 无提示」两帧的差异像素(本例 3781 像素,且全帧差异同为 3781)。
同理:模态对话框解耦要能**自动判定**(工作线程 + 超时,旧行为会永久阻塞 ⇒ 判失败并 `ExitProcess`,
探针不会挂住;再用 `EnumWindows` 数本进程 `#32770` 顶层窗口数量证明没弹框)。
95. ⚠️ **模态反馈必须是可关闭的**:`Application::SetModalErrorsEnabled` 默认 `true`,
`Initialize(instance, showCommand)` 按 `showCommand != SW_HIDE` **自动降级**为
`m_plot.ShowPersistentHint(text)`;隐藏窗口下弹模态框没人能点掉 = 进程被钉死。
96. **结构约束有脚本可验**:`make structcheck`(脚本 `Tools/structcheck.ps1`,入库、纯 ASCII、
退出码 0/1)**93 条断言**,共三组:
* **[1] 拆分**:文件存在与行数、职责函数是否**只**定义在预期文件、内部头的具名命名空间 + `inline` 计数、
`Build/Release/*.o` 是否齐全、**`nm` 断言绘制符号只在 `PlotViewPaint.o`**、每个实现目录都在 `Makefile` 里。
* **[2] 共享控件**:`UiControls.hpp` 声明的**每一个**控件是否**只由 `UiControls.o` 定义**(`nm`)、
调用点是否真的跨 ≥3 个组件、`Shared` 是否不依赖任何组件、底层 `PaintUtil` 是否不依赖 `Shared`。
⚠️ 判定控件要用「`UiControls.hpp` **声明**的名字」而不是「所有 `Ui*` 函数」
(`UiFillRect`/`UiText`/`UiMeasureTextWidth` 是底层原语,本就住在 `Utilities/PaintUtil`)。
⚠️ `Utilities/{PopupMenu,ColorPickerPopup}` 是历史上放在 `Utilities/` 下的自绘 UI 小部件,
**允许**消费 `Shared`(方向同组件),不要把它们判成违规。
* **[3] 本轮行为/注释不变量**(用户 2026-10-16 的三项要求):折起门限只接受
`DISCLOSURE`/`GROUP_TITLE`(全文件恰好 1 处)+ `ToggleGroupFromRow` 恰好 2 个入口 +
双击非名称本体 `return false`;改名框右边界 `statsRight - statsWidth - 6*scale` 且**不存在**
`right = statsRight;`;`SignalData`/`SignalGroup` **各只1个字符串字段且名为 `name`**、
全仓 `displayName`/`Title()`/`Set*DisplayName` **0 处**、过时措辞(`文件下标`/`所属文件下标`/
`文件分组`/`原文件下标`/`文件序号`)**0 处**、已修正注释(如 `DerivedSignalRequest::groupIndex`
→ 「原信号所属分组下标」)**确实存在**。
⚠️ 中文短语不能写在 `.ps1` 里(PowerShell 5.1 按 ANSI 读脚本)⇒ 放在 UTF-8 数据文件
`Tools/structcheck_phrases.txt`,由脚本以 `UTF8Encoding($false)` 读入后逐条断言(`forbid`/`require`)。
⚠️ **判别力已用 A/B 反证**:回退两处 UI 修复后 `panelanim` **147/156(9 项如实失败)**、
`structcheck` **90/93(3 项如实失败)**;恢复后 **156/156** 与 **93/93**。
71. ⚠️ **`FitAll()` 在没有任何可见信号时不得置位 `Tab().ViewInitialized()`**:
否则用户先连点几次「适应视图」(此时无信号被勾选)再勾选第一个信号,
`Application::SynchronizeView` 会**跳过初次自适应**,视界停在“无信号”那次的整段范围上,
单值信号的数据点既不居中、还会有一半被裁在网格外(用户 2026-10-15 报告)。
另外「适应视图」两条路径(初次自适应 / 工具栏按钮)必须**只有一条实现**(都走 `FitAll`)。
72. ⚠️ **上层元素的交互不得穿透到下层**(用户 2026-10-15 第六轮明确要求):
实验性功能的浮动面板浮在网格之上,`PlotView::OnDoubleClick` 必须**最先**用
`TopPanelAtPoint()` 判定并消费 —— 旧实现在"差值标签/光标标签/光标线/图例都不命中"时
无条件 `FitAll()`,于是在面板上双击会让网格莫名其妙地自适应一次。
新增/修改任何"空白处点击/双击"的默认行为时,都要先过一遍所有更上层的元素
(浮动面板、菜单、拾色器、装饰层)。
73. ⚠️ **网格变色高亮的触发来源只有两个**(旧行为"鼠标在网格里移动就高亮"**已作废**):
① 鼠标悬停在实验性功能浮动面板上 ⇒ 高亮该面板所属网格;
② 信号列表 / 样式区「目标」行**悬停到某条已绘制信号** ⇒ 高亮它所在网格**并且
该信号曲线本身也高亮**(`PaintSignalHalo`)。
`PlotView` 的 `m_externalHoverSignal` 必须与 `m_hoverSignal` **分开保存**
(绘图区 `OnMouseLeave` 会清掉后者,而列表悬停时鼠标不在绘图区内),
并由 `Application::Render()` **每帧**同步(覆盖 WM_MOUSELEAVE / 数据变化 / 切标签页)。
`SignalPanel::HoveredSignal()` 不能在 `InvalidateRows()` 之后继续有效(必须一并作废)。
74. ⚠️ **样式区「目标」行**(用户 2026-10-15 第六轮要求):位于样式自定义区**最上方**,
显示当前正在编辑的目标信号名称,**已绘制到网格时**在名称**后方**加 GX 标签;
悬停名称/GX 同时触发绘图区网格高亮 + 曲线高亮;**单击不进入编辑**(只做联动滚动),
**双击才进入改名**(`StyleEditField::SIGNAL_NAME` + `m_editRow < 0` 表示目标信号)。
它的名称宽度与"是否有 GX 标签"都可能在**未重排**时变化 ⇒ 必须用
`UpdateTargetRowGeometry()` 按需重算(带缓存键),不能只在 `LayoutStyleControls()` 里算一次。
75. ⚠️ **联动滚动必须是平滑动画,且目标不得被悬浮组名遮挡**(用户 2026-10-15 第六轮要求):
`SignalPanel::ScrollToSignalAnimated()` + `AdvanceScrollAnimation()`
(ease-out cubic;时长 = `clamp(距离×0.35ms, 130, 300)`;宿主 16ms 定时器驱动)。
向上滚动时顶部要**让出一个组行高度**(悬浮组名只可能是目标自己那个分组的组名,
因为分组在列表里连续排列)。用户滚轮 / 拖滚动条必须**立即取消**动画。
⚠️ 宿主定时器同时驱动「折起/展开动画」(见第 77 条):`Render` 的判定必须是
`ScrollAnimationActive() || GroupAnimationActive()`。
97. ⚠️ **分组折起/展开**动画结束的那一帧**必须重建行集合并按新内容高度刷新滚动条**:
`AdvanceGroupAnimations` 里任一方向结束都会 `m_rowsDirty = true` + `BuildRows()`(重算
`m_contentHeight` 与逐行位移),随后**每帧** `RefreshScrollBar(false)`。
旧实现只在折起结束时重建 ⇒ **展开后滚动条消失且不再回来**(用户 2026-10-16 报告)。
⚠️ `RefreshScrollBar(clampScroll = false)`:动画期间不能夹取滚动偏移(会打断 `scrollSynced`)。
⚠️ 探针必须以**逐帧重绘**驱动动画,否则 `EnsureRows()` 会把缺陷掩盖掉。
98. ⚠️ **单点信号(`finiteCount == 1`)固定使用「散点」**:`ApplySinglePointDefaultStyle` 写入
`DrawMode::SCATTER` + 最大线宽;信号面板「样式」行的「线段」「点线」对它**置灰且不可点**
(`SignalPanel::ModeOptionEnabled`,同时不参与悬停高亮);
`PlotView::UpdateAutoScatterStyles` **必须跳过单点信号**(否则会又把它改回「点线」)。
99. ⚠️⚠️ **折线顶点缓存(`PolylineCacheEntry`)的键必须包含所有“被烘进几何”的样式** ——
目前是 **`lineWidth`**(`BuildPolyline` 把每个像素列的上下包络向外扩 `max(1, 线宽)*scale*0.5`,
「梳状」判定也依赖它)。漏掉线宽会让**稠密信号**(采样数 > 绘图区宽)的线宽改动完全不生效
(用户 2026-10-16 报告:拖动线宽滑块无变化、松手后变了、再移动鼠标又变回去)。
新增任何影响顶点/包络几何的样式时,必须同步加入缓存键。
100. ⚠️ **“样式补丁”(`RepaintSceneCachePatch`)之后必须同步重建受影响的宽基底图层**
(`QueueLaneRebuild(patchLanes)` + `RenderPendingLaneRebuilds(1)`):
补丁只更新“场景缓存 + 后备缓冲区”,而每网格图层(宽基底)里仍是旧样式像素,
下一帧命中“宽基底快路径”时会把新样式**覆盖回旧像素**(同上一条的第二个根因)。
101. ⚠️ **`PopupMenu` 的“限高 + 可滚动 + 尾部固定项”**(`MenuLayoutConstraint::maxHeight`):
固定区 = **最后一个分隔线及其之后的条目**(如「新建网格」)钉在菜单底部,其余条目可滚动;
⚠️ 滚动区文本必须用 `UiTextClipped`(GDI 不遵循 `SetClip`)、**跳过完全不可见的条目**(性能);
`ItemAtPoint` 要求滚动区条目同时命中**滚动视口**;
⚠️⚠️ `BuildLayout` **不得无条件清零 `m_scrollOffset`**(`RebuildLayout` 会重走它 ⇒ 滚轮“滚不动”)。
`Application` 在菜单内滚动滚轮时**不关闭菜单**(其余情形仍是“滚轮关菜单”)。
102. ⚠️ **“点选信号 ⇒ 绘图区滚到目标网格”只在点击时生效**(`RequestPlotLaneScroll` /
`ApplyPendingPlotLaneScroll` / `PlotView::ScrollLaneIntoView`):**悬停绝不改变视图**;
目标网格已完整可见时**不动**。“目标”行 GX 标签点击 = 打开网格菜单(不滚动),悬停仍联动高亮。
103. ⚠️ **交互类缺陷的离屏探针必须“每帧重绘”驱动**(动画 / 分帧推进 / 自持帧):
面板 `Paint` → `EnsureRows()`、绘图区 `Paint` → 预算推进,都会在重绘时**顺手修复**一部分
中间态;只在动作结束后才重绘会把“完成帧”的缺陷掩盖掉(本轮滚动条缺陷就因此假绿过一次)。
104. ⚠️ **可滚动菜单的固定区是“两端”**:`[0, m_scrollFirst)` = 前导标题(`HEADER` / `HEADER_ACTION`,
如「所属网格」)**钉在顶部不随滚动移动**;`[m_pinnedIndex, end)` = 最后一个分隔线及其之后的条目
(如「新建网格」)钉在底部;只有中间 `[m_scrollFirst, m_pinnedIndex)` 是滚动区。
裁剪(`Paint`)、命中(`ItemAtPoint` 要求条目同时落在滚动视口内)与 `EnsureItemVisible` 的边界
都必须用这个区间,否则标题会被滚走(用户 2026-10-16 报告)。
105. ⚠️ **菜单长度优先用「行数上限」表达**:`MenuLayoutConstraint::maxScrollRows`
(滚动区最多显示 N 个标准行;与 `maxHeight` 同时给出时取更严格者)。
当前「所属网格」菜单 = **10 行**(`kLaneMenuVisibleRows`)+ 右侧滚动条。
滚动条必须**可拖拽**(按下滑块/轨道开始拖、`OnMouseMove` 只改偏移不改悬停、抬起结束且不下发命令)。
⚠️⚠️ **`BuildLayout` 绝不能复位 `m_scrollBarDragging`**:拖拽时每帧都会 `RebuildLayout()`,
在那里清零会让拖拽“拖一下就脱手”。
106. ⚠️ **绘图区的“网格滚动到可视区”必须是平滑动画**(与信号列表同源:ease-out cubic、
时长 `clamp(距离×0.35ms, 130, 300)`、宿主 16ms 定时器驱动):
`ScrollLaneIntoView` 只负责**启动**动画(`m_laneScrollAnimActive`),`AdvanceLaneScrollAnimation`
逐帧推进,`Render` 的定时器判定要带上 `LaneScrollAnimationActive()`。
⚠️ 用户自己滚动网格(滚轮 / 拖滚动条 / 键盘)时必须 `CancelLaneScrollAnimation()`,否则两者抢滚动位置。
107. ⚠️ **判断“动画是否真的在动态过渡”必须看**刚触发时**的状态**:只断言“最终把目标滚进可视区”
对“瞬移”同样成立、毫无判别力。要断言:触发后立即仍几乎未动、中间帧单调递进、
收尾精确到位(本轮 A/B 就是靠这三条排除了“直接赋值”的实现)。
> ⚠️⚠️ **编号注记(2026-10-16)**:提交 `5b52eef`(已于 **§9.41** 整体回退)曾以 **108–114** 号
> 写入下列规则,**它们全部作废**:108「`panelOnly = !chromeDirty && (m_panelDirty || panelAnimating)`」、
> 109「动画帧必须**精确失效**、禁止整窗失效(`InvalidatePanelRect`/`InvalidatePlotRect`)」、
> 110「`WM_PAINT` 辨认“更新区域完全在面板内”(`m_panelDirty`)+ `m_paintPlot` 跳过整个 `PlotView::Paint`
> + 回送范围改为“本帧重绘区域并集”」、111「联动滚动前先展开折起分组」、112「适应视图不得改变 `LaneScroll`」、
> 113「右下角回到最上方浮动按钮」、114「`Copy-Item` 保留时间戳」。
> **其中 111 / 112 / 113 / 114 的内容本身是对的,已并入下面的 111–113 条继续生效**;
> **108 的一半(“动画由动画状态本身驱动重绘”)正确、实现改为“计入整帧脏 + 整窗失效”;
> 109 / 110 属于被证伪的方案 —— 禁止再用。**
108. ⚠️⚠️ **`Render()` 的窗口回送必须存在“无条件兜底分支”**:
`if (fullFrame) { BitBlt(整窗) } else { BitBlt(绘图区) }` —— **绝不能**写成
`if (fullFrame) … else if (某条件) …`。否则存在“两个条件都不满足”的帧组合
(典型:`WM_MOUSEMOVE` 的 `plotOnlyMove` 路径只失效绘图区,既不动 `m_chromeDirty` 也不动面板脏),
这些帧会**一个像素都不回送** ⇒ 后备缓冲区正确、屏幕却停在旧画面
(用户表现:悬停高亮卡住、滚动条滑块跟不动、拖拽信号看不到结果)。
**回送区域永远“宁多勿少”**;省回送带宽的收益远小于一次屏幕不刷新的代价。
109. ⚠️⚠️ **禁止“按窗口更新区域决定本帧画什么 / 回送什么”的优化**(`m_paintPlot` / `m_panelDirty` /
`panelOnly` / 并集回送这一整套都已删除)。它把正确性绑在 `GetUpdateRect` 与各失效点“恰好配套”上,
一旦出现新的失效路径(如本轮“只失效绘图区”的鼠标移动)就会静默出错;
而且**它无法被离屏探针验证**(见第 113 条)。
110. ⚠️ **面板动画必须由“动画状态本身”驱动重绘**(正确洞见保留):`WM_TIMER` 不置 `m_chromeDirty`,
所以 `Render` 要把 `m_panel.ScrollAnimationActive() || m_panel.GroupAnimationActive()` 计入整帧脏,
否则某些帧根本不重画面板(用户:“过渡动画有时直接没了”)。
**实现方式改为整窗失效**(不是“只失效面板矩形”)。
111. ⚠️ **联动滚动前必须先展开折起的分组**:折起的分组不列出信号行 ⇒ 行集合里找不到目标,
`SignalScrollTarget` 直接返回、列表不动。
✓ `SignalPanel::ExpandGroupForSignal()`(**立刻生效**、不播展开动画,因为滚动目标需要稳定行位置),
由 `ScrollToSignalAnimated()` 在算滚动目标前调用。
112. ⚠️ **「适应视图」不得改变网格纵向滚动**:`FitAll()` 与 `FitSinglePointCluster()` 里那两次
`Tab().LaneScroll() = 0` 已删除(用户明确要求“适应视图不要影响滚动条”),只取消滚动动画。
右下角「回到最上方」浮动按钮(仅 `m_scrollBarVisible` 且已下滚 `> kBackToTopShowOffset`(24)
时出现;170ms ease-out cubic 淡入淡出;按下只登记 `m_backToTopPressed`、**不进入拖拽**;
抬起仍在同一按钮上才 `ScrollLanesToTop()`)与「网格滚动到可视区」的平滑动画继续生效。
113. ⚠️⚠️ **离屏探针无法验证“窗口回送 / 合成”**:窗口在屏幕外时 DWM 不合成,`GetDC` 读回的窗口 DC
恒为空白 ⇒ “画进了后备缓冲区但没回送窗口”的缺陷在离屏探针里**永远假绿**(本轮就是这样)。
**回送 / 合成相关的约束必须有源码级护栏**(本机:`.preflight/diag/verify_blit.ps1`,断言回送块
存在无条件 `else`、无 `else if`、且 `m_paintPlot`/`m_panelDirty`/`panelOnly` 不存在)。
114. ⚠️ **离屏探针的消息泵必须丢弃 `WM_MOUSELEAVE`**:产品在 `WM_MOUSEMOVE` 里注册
`TrackMouseEvent(TME_LEAVE)`,而离屏窗口上真实鼠标不在它上面 ⇒ 系统**立刻**兑现一条
`WM_MOUSELEAVE`,刚设置的悬停态马上被清掉(表现为“探针永远测不到悬停”)。仅影响探针。
115. ⚠️ **涉及“网格会被回收 / 下标会左移”的断言必须用锚信号**:`MoveSignalToLane` 把源网格移空后
该网格被回收,其后所有网格下标**左移**(实测 124 → 123)⇒ 「移动后下标 == 目标下标」会假失败;
改为「移动后与**目标网格里的某个锚信号**同网格」。
116. ⚠️ **对“看起来坏了”的现象,先怀疑探针自己的取点与判据**:本轮先后误判“悬停不生效”
“信号没换网格”,实际都是探针取点踩到了「GX 重排徽标 / 图例 / 时间光标线」这些
**优先级更高的热区**,或用了错误的比较基准(拿滚动后的 rect 当比较区域)。
117. ⚠️⚠️ **宽基底快路径里拿不到图层像素的网格必须【就地补画网格底衬】,绝不允许留白**:
旧实现 `continue`(留白)或用旧跨度像素 `StretchBlt` 放大占位(深度放大 ⇒ 一片无网格的模糊色块)
—— 两者在用户眼里都是「网格叠底消失,没有任何内容」(用户 2026-10-17 报告)。
✓ `PaintLaneGridBacking`(经 `m_renderGridOnly` 复用 `PaintLane` 的网格部分:底色 + 网格线 + 分界线,
跳过波形/散点/派生曲线/Y 轴刻度/图例)。**底衬代价极低 ⇒ 每帧都可以画**。
⚠️ 顺序:**先画底衬(GDI+)再 `g.GetHDC()` 做 BitBlt**(取到 HDC 后不能再调 GDI+ 绘制)。
⚠️ 只有**所有**网格都贴不出像素时才返回 false(交给整幅重绘),否则会停在「只有底衬、没有波形」。
⚠️ 绝不要恢复 `StretchBlt` 占位拉伸。
118. ⚠️ **待重画队列必须【可视网格优先】**(`RenderPendingLaneRebuilds` 里 `std::stable_partition` +
`LaneVisuallyRelevant`):队列是按下标顺序登记的,上百个网格时用户正在看的那些要等上百帧
⇒ 表现是「需要等一会才正确加载」(用户 2026-10-17 报告)。同理「结算」判据用
**`LanesVisiblyReadyForView()`**(不要 `LanesAllReadyForView()`:离屏图层与画面无关,会让结算被拖后上百帧)。
119. ⚠️⚠️ **“是否请求宿主再送一帧”只能看【还有没有可视网格待补】**(`AnyVisibleLanePending()`),
**绝不能**用「`m_layerRebuildPending` 非空」:上百个网格时队列长期残留大量**离屏**网格 ⇒ 每帧都请求重绘
⇒ 700ms 内挤出 100+ 次重绘,而 `WM_TIMER` 优先级**低于投递消息** ⇒ **动画定时器被饿死**
(实测 `frames=124 / timerTicks=1`;改为可视判定后 `frames=25 / timerTicks=16`)。离屏网格改为**机会式**预热。
120. ⚠️⚠️ **`SetTimer` 的间隔会被量化到系统 tick(默认 15.6ms)**:请求 16ms 实际等到**下一个** tick
⇒ 实测帧距 **≈26ms(只有约 38fps)**,所有过渡动画都"不平顺"(用户 2026-10-17 报告)。
✓ 动画定时器周期必须 **≤ 一个 tick**(当前 `kScrollTimerPeriodMs = 10` ⇒ 实测 ~15.5ms ≈ 64fps)。
实测映射:8ms→15.8 / 10ms→15.5 / 12ms→16.5 / **16ms→26.4** / 31ms→37.9。
⚠️ `timeBeginPeriod(1)` 在本机**无效**(已 A/B 验证),不要指望它。
121. ⚠️ **「宽基底可用性」判据(`SceneGeometryHash`)绝不能计入网格纵向滚动量 / 各网格屏幕纵向位置**
(`SceneContentHash(..., includeLaneScroll=false)`):它们只是图层的**贴图落点**,
图层内容完全不变(`BlitWideBuffer` 按 `lane.rect` 逐网格贴图)。
若计入 ⇒「网格滚动到可视区 / 回到最上方」**每一帧都退化成整幅重绘**
(实测单帧 18.36ms → 1.19ms)。⚠️ **静态场景缓存**的指纹**仍然必须**计入 `LaneScroll()`(缓存内容取决于它)。
122. ⚠️ **「放大自动点线」判据(用户 2026-10-17 重新定义,推翻「一屏内数据点 < N×4」)**:
按 `GridMinorCellCount()` 切成横向小格,**只要存在任何一个小格内有效数据点 < 5** 就切「点线」
(`PlotView::AutoPointLineNeeded`)。实现:先「总有效点 < N×5」O(1) 充分判定;
单调时间轴逐小格**两次二分**(单格累计到 5 个即早退,另给 `kAutoPointLineCellScanCap=512` 封顶);
非单调时一次有界扫描分桶。保留「视界内至少一个可见点」的保护。
⚠️ **光标吸附仍用旧的整屏判据**(`SignalSnapEnabled`)—— 用户明确要求“其它规则不变”,
§9.30 的「自动点线 / 吸附同源」自本轮起**不再成立**。
123. ⚠️ **光标工具激活且【没有任何信号绘制】时,首个信号出现后光标要按规则重新出现**:
`PlotView::ResetCursorPlacementOnFreshContent()`(规则与 `CycleCursorMode()` 首次启用**同源**),
由 `Application::SynchronizeView` 的 `else if (!tab.ViewInitialized())`(= 首次出现可见信号)分支在
`FitAll()` **之后**调用(用户 2026-10-17 要求:不要再停在旧的残留位置)。
124. ⚠️ **探针里的动画计时**:启动动画后必须**先画一帧**(`Render` 才负责 `SetTimer` 武装动画定时器),
否则真实消息循环无消息可等 ⇒ `frames=0` 假失败。真实循环计时还必须把窗口置为
**「离屏但可见」**(`-30000,-30000` + `WS_EX_TOOLWINDOW` + `SWP_SHOWWINDOW|SWP_NOACTIVATE`),
隐藏窗口**没有** `WM_PAINT`。
⚠️ **`theme.cardBg == theme.plotBg`(都是 kBase3)**⇒ 用颜色区分「空白/已绘制」的探针判据是**色盲**的;
判断网格是否画出必须用结构判据(竖直锐边),且必须要求「同一列多行都有锐变」(否则网格**分界线**会让
每列都像有锐边)。⚠️ **后备缓冲区是持久的**:宽基底路径只把绘图区填成 plotBg(==cardBg),
本帧没被覆盖的网格**保留上一帧像素** ⇒ 探针「看不到空白」不能证明没问题。
125. ⚠️⚠️ **动画定时器的失效范围必须与 `Render` 的绘制/回送判定配套**(用户 2026-10-17 报告:
“滚动过渡动画甚至不如手动拖滚动条”):`WM_TIMER(kScrollTimerId)` **不能无条件整窗失效** ——
整窗更新区域会让 `WM_PAINT` 把 `m_chromeDirty` 置真 ⇒ **每帧重绘标签栏/工具栏/面板**。
✓ 用**推进之前**的面板动画状态判定:面板动画 ⇒ `InvalidateRect(nullptr)`;
只有绘图区动画 ⇒ `InvalidatePlotRect`(只重绘、只回送绘图区)。
⚠️ 这不违反第 109 条:回送分支仍是 `if (fullFrame) {整窗} else {绘图区}`,**没有**按更新区域决定画什么。
实测:绘图区动画帧 **13.6ms → 3.5ms**(手动拖滚动条参照 4.2ms)。
126. ⚠️⚠️ **「是否再请求一帧去补图层」必须兼顾两个极端**(`AnyVisibleLanePending` + `PrefetchStepAllowed`):
* 只看「严格可见」⇒ 离屏网格长期不建图层,长距离滚动动画扫进去时**每帧十几个网格没有图层像素**,
只能靠底衬兜底重画网格(实测 **+8ms/帧**,动画明显卡);因此可视判定要带 **±1 屏预取带**
(`LaneVisuallyRelevant(laneIndex, prefetchScreens)`);
* 只看「队列非空」⇒ 上百个离屏网格挤出一秒以上的高频重绘,把 `WM_TIMER`(优先级低于投递消息)**饿死**;
因此只剩离屏网格时要**限速**(`PrefetchStepAllowed()`,`kPrefetchMinIntervalMs = 32` ⇒ 约 31 次/秒)。
实测:`lane-only + VERY_BIG_DATA` 帧 **13.9ms → 5.3ms**。
127. ⚠️ **「宽基底快路径」对拿不到图层像素的网格要就地补画网格底衬**(见第 117 条),
但**底衬不是免费的**:约 **0.5ms/网格**(`PaintLane` 的网格部分:y/时间刻度 + 3 次批量 GDI)。
⇒ 必须靠第 126 条的预热把「可见但没建过图层的网格」数量压到接近 0。
128. ⚠️⚠️ **快速连点会丢一次按下**(`CS_DBLCLKS`):第二次及之后的快速按下被合成为 `WM_LBUTTONDBLCLK`。
**所有没有双击语义的控件**都必须把它当作一次「按下」(不只是工具栏,见第 84 条):
✓ 组件各自实现 `DoubleClickIsPlainPress(x, y)`,宿主据此在 `WM_LBUTTONDBLCLK` 里选择走
`OnLButtonDown` 还是 `OnDoubleClick`。覆盖:`PlotView`(浮动面板及其复选框/滑块/复位/✕/标题栏、
「回到最上方」按钮、绘图区滚动条、GX 徽标)与 `SignalPanel`(复选框、折叠三角、「组X」、✕、
GX 徽标、全部显示隐藏、绝对值、色块、绘制方式、滑块、调整按钮、筛选框、样式区/空白区域)。
⚠️ `SignalPanel::DoubleClickIsPlainPress` 对**列表之外**的落点必须返回 **true**(样式区按钮只有单击语义);
返回 false 会让连点被 `OnDoubleClick` 吃掉。
129. ⚠️ **分组行点击(用户 2026-10-17 修订,推翻 §9.37 ①)**:
**三角 / 「组X」标签 / 名称本体**都折叠展开分组(不再“点名称不折起”);
只有**快速双击**名称才进入改名,阈值 `kGroupRenameDoubleClickMs = 300`(**比 `GetDoubleClickTime()` 紧**),
超阈值的“慢双击”只当**又一次单击**;快速双击进入改名时要**先撤销**按下那一次造成的折叠切换
(`m_groupNameClickWasCollapsed`),保证进编辑时折叠状态与点击前一致。
130. ⚠️⚠️ **滚动过渡动画的三个独立根因**(排查时用「手动拖滚动条」当参照物):
① 定时器失效范围(第 125 条);② 图层预热不足(第 126 条);
③ 网格滚动量参与“宽基底可用性”判据(第 121 条)。
131. ⚠️ **「全部信号各自一块网格」时待重画队列天然有上百项**:任何「队列非空就做 X」的逻辑都要
重新审视(重绘请求、预热、结算判据…),否则要么饿死动画定时器、要么让画面长期停在兜底状态。
132. ⚠️⚠️ **不要用 PowerShell `Get-Content -Raw | Set-Content -Encoding UTF8` 改源码**(本轮再次踩到):
PS 5.1 按 ANSI/GBK 解码 UTF-8 ⇒ 中文注释整份变乱码 + BOM。✓ 乱码**无损可逆**:
读成 UTF-8 字符串 → `GetEncoding(936).GetBytes()` 写回即可还原(再删掉 BOM 变成的 `?` 首字节)。
正确做法:用编辑工具,或 `[System.IO.File]::ReadAllText/WriteAllText(..., UTF8Encoding($false))`。
133. ⚠️ **动画性能 A/B 必须在同一二进制、同一起始状态下对比**:靠“改代码重编”做 A/B 会被
**两次运行之间不同的预热状态**污染(本轮因此得出过“底衬兜底 +8ms”的误导结论);
应加**运行时开关**在**同一次运行**内切换。
134. ⚠️⚠️ **`BlitWideBuffer()` 只要还能画网格底衬就必须返回 true,绝不能因为“一个网格都贴不出图层像素”
就退化成整幅重绘**:滚动到从未建过图层的区域时这是必现情形,退化成整幅重绘会把**本帧全部可见网格的
波形**(可能含千万级采样的巨型信号)每帧重画一遍 —— 实测单帧 **20~35ms**(用户 2026-10-17 报告:
滚动动画在巨型信号处卡顿,“本不该受网格内信号绘制的影响”)。
✓ 只有 `blits` 与 `backing` **都为空**时才返回 false。这正是“没来得及画信号就按只有网格叠底的状态继续滚动”。
实测:动画均值 23→**11.2ms**、最坏 35.6→**13.1ms**;含 5 个巨型信号网格的帧 33→**~11ms**。
135. ⚠️ **面板滚动动画期间只重绘面板,不要重绘标签栏/工具栏/整窗底色**:
由滚动定时器置 `Application::m_panelDirty`,`Render` 走 `else if (panelOnly)` 分支
(`SignalPanel::Paint` 会铺满自己的区域)。实测 −2.7ms/帧。
⚠️ 这不是 §9.41 禁止的“按更新区域决定画什么”:**像素由谁改变,就由那条路径显式置位**;
**回送**仍是 `if (fullFrame) {整窗} else {绘图区}` 的无条件分支。
136. ⚠️ **`LanePanelState::open` 的默认值是 `true`**:任何“哪些面板正在显示”的统计都必须同时检查
功能是否启用(`lane.edgeCount / edgeCenter / meanFilter / weakPeak`),否则会把**所有**网格都算成“已打开”。
137. ⚠️ **浮动面板首次出现的位置必须锚定在右上角**:旧实现用 `top = plotTop + 16 + laneIndex*28` 错开,
上百个网格时 `laneIndex` 很大 ⇒ 被夹到**右下角**(用户 2026-10-17 报告)。
✓ 改用「当前真正显示出来的面板中排在本面板之前的个数」错开,并把错开量夹到
“面板下沿不超过绘图区 45% 高度”以内 ⇒ 第一个面板正好上右角(`topMargin/rightMargin = 16*scale`)。
⚠️ 任何与 `laneIndex` 线性相关的屏幕坐标都必须重新审视。
138. ⚠️⚠️ **`SetLane*`(实验性功能开关)末尾绝不能调用 `RebuildWideBuffer()`**:它**同步重渲染全部网格图层**,
129 个网格(含千万级采样的巨型信号)整批重建要**几百毫秒** ⇒ 点一下开关 / 关闭浮动面板就“卡住一段时间”
(用户 2026-10-17 报告)。✓ 用 `EnsureLaneLayersPrefetched()`:只有图层数与网格数不符才整批建立,
其余只登记进待重画队列、由既有预算(每帧最多一个)逐帧补齐。实测几百毫秒 → **≤22ms**。
139. ⚠️ **分组行的「N 项」计数是独立热区 `RowPart::GROUP_COUNT`**(`GroupStatsRect` 为唯一几何来源):
**任何**点击频率都只折叠/展开、绝不进入改名(用户 2026-10-17 要求)。
⚠️ 「快速双击进改名」的计时只对 `RowPart::BODY` 记录 —— 否则“点计数、紧接着点名称”会被误判成双击。
140. ⚠️ **“修完感觉没变”时要重新插桩定位**:本轮在 `Paint` 里插 5 个 `QueryPerformanceCounter` 才定位到
“宽基底分支内部几乎不耗时 ⇒ 一定走了别的分支(整幅重绘)”。逐阶段计时比“改代码做 A/B”快得多。
141. ⚠️⚠️ **`RenderLaneLayerRegion` 内部绝不能调用 `BuildLaneVisuals()`**(本轮实测:253 网格时
`RollWideBuffer` 单帧 **26.8ms**,全是“每次补条 × 2 次 O(网格数) 的 `ComputeLaneRange`”):
它只渲染**一个**网格,进入前快照 `m_lanes` / `m_laneContentHeight`,结束时直接拷回即可。
142. ⚠️ **图层“横向重新居中”是纯优化,不是正确性要求**:`BlitWideBuffer` 按
`(ViewMin - layer.wideMin) * scale` 算源偏移 ⇒ 不滚动也画得对(只要还在位图覆盖内)。
因此必须:`kLayerRollQuantumX = 32`(不足一个量子不滚)+ `kLayerRollsPerFrame = 3`(分帧摊销)。
143. ⚠️⚠️ **位移过大时绝不可以在前台同步“整块重画”**:巨型信号(25M 采样)一次整块重画
**20~130ms**(实测拖拽最坏帧 28ms)。超出 `kLayerRollMaxShiftPixels = 160` 就**作废图层 + 登记待重画**,
本帧由 `BlitWideBuffer` 的网格底衬兜底。
⚠️ 同时 `RollWideBuffer` 必须**跳过不在可视/预取带内的网格**(离屏网格做像素工作纯属浪费)。
144. ⚠️ **包络带路径也要支持横向补条裁剪**:包络是**逐像素列独立**计算的,只构建条带覆盖的列即可;
旧实现对包络带总是构建整幅可见范围 ⇒ 巨型信号一条 32px 竖带要 5ms。
145. ⚠️⚠️ **折线命中测试的横向窗口必须包含“跨窗口线段”**:`upper_bound(fx+tol)` 给出的是
**第一个超出容差的顶点**,而“一端在窗口内、另一端在窗口外”的线段正是鼠标所在的那一段 ⇒
线段扫描上界必须是 `min(stop, windowLast + 1)`。否则放大到点线模式后
**两点之间的连线完全点不中**(用户 2026-10-17 报告)。
146. ⚠️ **散点模式也要有悬停高亮**(用户 2026-10-17 要求):`PaintSignalHalo` 对 `DrawMode::SCATTER`
不能再直接 `return`,而应给每个参与绘制的散点套强调色圆环(步长与绘制同源 `kMaxScatterPoints`)。
⚠️ 列表 / 图例 / 样式区「目标」行的联动高亮走**同一条** `PaintSignalHalo` 路径,改一处即可全覆盖。
147. ⚠️ **「选区放大」模式必须保留悬停高亮**(用户 2026-10-17 修订 §9.34 的旧规则):
`OnMouseMove` 里不应再有 `ZoomMode() == NONE` 的门控;`OnLButtonDown` 在 `SELECT` 模式下仍然直接
进入 `RUBBER_SELECT`(拖信号只在 `NONE` 模式成立)⇒ “能高亮、能看提示、但拖不动”。
该模式下的提示文案**只显示信号名**(去掉“· 按住拖拽可移入其它网格”尾缀)。
148. ⚠️⚠️ **「每小格内数据点 < N」的判定必须限制在【该信号真正有数据的时间段】内**:
否则同时加载“跨度很长的大文件 + 跨度很短的信号”时,短信号在**大部分格子里本来就没有采样点** ⇒
“存在空格子”恒成立 ⇒ **默认视图就被误判成稀疏、自动切点线**(用户 2026-10-17 报告;
实测 248 个 MF4/中等信号全部误判)。✓ 用可见数据的 `[dataMin, dataMax]` 收窄参与的格子
(`firstCell..lastCell`),鸽巢判定的分母也改用**参与格子数**。
149. ⚠️ **「放大自动点线」只对 `DrawMode::LINE` 生效**(用户 2026-10-17 要求):
`SCATTER` 不换(散点已足够提示数据点位置)、`LINE_SCATTER` 不动;用户手动改过
(`autoScatterUserOverride`)时也不切换,避免“改完下一帧又被改回去”。
150. ⚠️⚠️ **探针的绝对毫秒阈值不可靠**:同一份基线代码在不同时段跑,数值可差 2~3 倍
(本轮 11ms → 26ms,普通整帧 8ms → 21ms)⇒ 断言必须用**同一时段测得的相对基准**
(普通整帧耗时 / 手动拖滚动条耗时)。⚠️ 另外 **printf 的参数必须与格式串数量一致**,
否则读到栈上垃圾值(本轮把 `130.49` 当成真实耗时,误导排查)。
151. ⚠️⚠️ **内联改名期间,「名称区域整行」都必须吸收鼠标**(用户 2026-10-19 报告):
行高大于输入框高度(上下各留 3 逻辑像素),起点落在内边距上时旧实现会继续走行点击分支
⇒ **分组被展开/收起**。✓ `SignalPanel::NameEditAbsorbRect(field)` = 输入框横向范围 × 整行高度;
`OnLButtonDown` / `OnEditMouseDown` 命中该区域一律按“文本按下”处理(把落点夹回输入框再算插入符),
`OnDoubleClick` 在编辑态命中该区域**直接返回 false**(不折叠、不再次改名)。
⚠️ 横向仍限定在名称区域:同一行的复选框 / 「N 项」/ GX 徽标 / ✕ 继续可点。
⚠️ 推论:任何“行内可编辑控件”的命中区都必须**覆盖到它所在行的高度**,否则会被行级点击抢走。
152. ⚠️ **浮动面板标题栏的「GX」徽标是独立热区**(用户 2026-10-19 要求):
`EdgePanelLayout::badge` 是**绘制 / 悬停提示 / 手型光标 / 点击联动**的唯一几何来源;
命中判定必须排在 `layout.title` **之前**(徽标位于标题栏矩形内部,否则会被判成“拖动面板”)。
点击调用 `ScrollLaneIntoView(laneIndex)`(自带“已完整可见 ⇒ 不动作” + 平滑动画)。
⚠️ 悬停提示气泡必须在**面板像素图层全部存进缓存之后**绘制(见 `PaintEdgeCursor` 末尾),
否则气泡会被烘进面板位图、鼠标移开后仍残留。
153. ⚠️⚠️ **`m_minLaneHeight` 只存逻辑像素,使用处才乘 `m_scale`**:旧实现 `Layout()`
写成 `54.0f * m_scale` 而 `BuildLaneVisuals()` 又乘一次 ⇒ 125% 缩放下实际最小高度是
`54 × 1.5625`,且与「是否需要滚动条」的判定(只乘一次)不一致 ⇒ 可能出现“网格溢出却没有滚动条”。
当前值 = **81 逻辑像素**(用户 2026-10-19 要求:原 54 的 1.5 倍,保证最小网格下也看得清跳变)。
新增任何“按最小网格高度推导”的逻辑(滚动条判定、`FitLaneHeight`、内容高度)都必须用同一个换算。
154. ⚠️⚠️ **`.mat` 绝不整文件读入堆内存**(用户 2026-10-19 报告:GB 级 SDI 导出):
`MatHdf5::Open` 现在是 **`CreateFileW` + `CreateFileMappingW` + `MapViewOfFile`**,
`m_data` 是一个“只读字节数组”视图(`MappedBytes`:`size/data/begin/end/[]`),
因此解析代码一行不用改,但**虚拟地址 ≠ 物理内存**,未访问页不驻留。
⚠️ 析构必须 `UnmapViewOfFile` → `CloseHandle(mapping)` → `CloseHandle(file)`(顺序不能反)。
155. ⚠️⚠️ **大数据集解码必须“逐 chunk 解压后直接写进目标 double 列”**:
`MatHdf5::ReadDatasetInfo`(只读元信息)+ `StreamDataset(ByteSink)`(连续布局按 4MB 切片、
分块布局逐 chunk inflate)取代了“整个数据集字节数组 + 再 memcpy 成 double”。
`ReadDoubleStream` 进一步把 SDI 的 `#data#`(uint8 字节流)按 8 字节一组直接写成 double,
并用最多 7 字节的尾巴处理“chunk 边界不与 8 字节对齐”。
⇒ 峰值 = 文件映射 + 最终 double;**不要再恢复 `Dataset.data` 的整块分配**(GB 级下等于翻倍)。
156. ⚠️⚠️ **分块散射必须合并“目标连续的行”**:SDI 的 `#data#` 是 `dims=[N]`、chunk `[N,1]`,
归一化后 rank=2 ⇒ 每行只有 1 字节。若逐行回调(且每行新建 `coordinate` 向量),
实测解压吞吐只有 **11MB/s**(1.29GB 要 117s);把连续行合并成一次 sink 后 **~110MB/s(12.5s)**。
新增 HDF5 数据类型时不要退回“逐行 emit + 每行分配”。
157. ⚠️ **SDI 的时间轴按内容去重**:每条 timeseries 都有自己的时间流,但内容通常完全一致。
加载时若同一分组已有**逐字节相同**的时间列,直接复用同一 `TableBlock`(否则每条信号各存一份:
1.29GB/5 信号的文件多占 ~500MB)。实测私有内存增量 1234MB → **740MB**(数据本身 739MB)。
158. ⚠️⚠️ **MDF4 的数据块不只有 `##DT`**(用户 2026-10-19 报告:SDI 导出的 8 个信号只读出 4 个):
DG 的 data 链接可能是 **`##DL`(数据列表)** / **`##HL`(头部列表)** / **`##DZ`(压缩)**,
记录还可能**跨段**(`##DT` 载荷不是记录长度的整数倍)。正确做法:
`CollectDataSegments`(递归展开 DL/HL,识别 DT/DV/DZ)+ `ReadRecordSegments`
(跨段拼接、压缩段先 inflate、非压缩段仍按 4MB 分块读)。
⚠️ 压缩段不能用「载荷 ÷ 周期数」反推记录长度(那是压缩后长度)⇒ 改用
`max(cg_record_id_len*8, 各通道 bitOffset+bitCount)` 向上对齐到 4 字节。
⚠️ `##SD`(已排序数据)仍未实现,这类数据组会被跳过。
159. ⚠️ **光标数据点圆点被底部「时间读数 / Δt 面板」盖住时要改成向下三角**(用户 2026-10-19 要求):
`MarkerCoveredByCursorLabel`(圆心落入面板或重叠面积 ≥ 半个圆)+ `CoverHiddenMarker`
(置 `offDown`,把锚点放到面板上沿上方 `depth/2 + 1`)。两处调用:原始信号与派生曲线胶囊。
⚠️ 时间读数/Δt 是在取值标注**之后**绘制的,所以圆点必然被盖住;
⚠️ 像素级断言必须钉在**标记锚点附近的小框**:取值胶囊也用信号色作底,
数“面板上方的信号色像素”会把漏画三角误判为通过(A/B 已证)。
160. ⚠️⚠️ **MDF4 数值换算按 `cc_type` 分派,参数个数不匹配时必须原样返回**(用户 2026-10-21 要求
支持特性曲线 / 公式 2/3/4/5/6):`1` 线性 `v0+v1*raw`;`2` 有理式 `(v0+v1*raw)/(v2+v3*raw)`(需 ≥4);
`3` 代数式 `(v0*raw²+v1*raw+v2)/(v3*raw²+v4*raw+v5)`(需 ≥6);`4` 表插值(`(键,值)` 交错、
需偶数且 ≥4,端点外夹紧);`5` 表阶梯(同一张表,取 ≤raw 的最后一段);`6` 区间映射
(`(下限,上限,物理值)` 三元组、需 3 的倍数且 ≥3)。⚠️ 分母为 0、参数个数不符、表为空
**一律返回原始值**(宁可显示未标定值,也不要显示编造值);⚠️ 线性换算 `v0 == v1 == 0`
视为「未配置」而非「恒为 0」。实测 4 个真实文件只用到 `0/1/7` ⇒ 2–6 只能用**合成文件**验证。
161. ⚠️⚠️ **`##CA` 数组块的维度表从数据段 +16 开始、步长 8 字节**:`+0 ca_type`、
`+1 ca_storage`、`+2 ndim(U16)`、`+4 flags(U32)`、`+16 dim_size[U64]`(Simulink 与 CANape
导出的文件已实测一致)。**不要**写成 +8 或按 4 字节步长读,否则维度全错。
162. ⚠️⚠️ **`##CA` 只支持 `ca_storage == 0`**(所有元素在记录里首尾相连,`bit_offset + i*bit_count`):
展开成多列需 `1 < 元素数 ≤ 1024` 且 `bit_count != 0`,名字 `name[i]`(1 维)/ `name[i][j]`(2 维),
换算与枚举要**复制**给每个元素。`ca_storage != 0`(按通道/按引用存放)暂不支持 ⇒ 保留原样列,
**不要**猜元素位置。
163. ⚠️⚠️ **数值型 VLSD 不要求 `cn_bit_count > 0`**:VLSD 通道的 `bit_count` 常写 0,元素位宽要
`InferVlsdElementBits`(按样本长度优先整除 64,再 32/16/8)反推;多元素样本**取第一个元素**。
判定数值型时只要求 `cn_data_type` 是数值类型,不能再要求位宽(旧实现会让整列被丢弃)。
164. ⚠️ **VLSD 通道不得作为主通道(时间轴)候选**:其记录里存的是 `##SD` 偏移,不是物理量。
`SelectMasterChannel` 的顺序是「`cn_sync_type` 命中**且**数值**且**非 VLSD」→ 名字命中 → 首个数值通道。
165. ⚠️⚠️ **`##CA` 的所有字段都必须按「块长度」做边界检查后再读**:数据段布局是
`+0 ca_type` / `+1 ca_storage` / `+2 ndim(U16)` / `+4 flags(U32)` /
`+8 ca_byte_offset_base(U16)` / `+10 ca_inval_bit_pos(U16)` / `+16 dim_size[](U64 × ndim)`,
随后才是 **可选**的 `cycle_count[](U32 × ndim)`。
⚠️ 实测 CANape 写出的文件**没有** `cycle_count[]`(块长度恰好到 `dim_size[]` 结束)⇒
盲读会读到下一个块头(实测得到 1481909027),必须用 `BlockLength - (data - block)` 判断。
166. ⚠️⚠️ **`##CA` 能否展开由「记录容量」决定,不再用元素数魔数**:
`bit_offset + 元素数 × 位宽 ≤ cg_data_bytes × 8` 是真正的完整性校验(元素是否真的都在本记录内);
再加上 `ca_storage == 0`、`ca_type ∈ {0,1,2}`、`cn_bit_count ∈ [1,64]`、`cycle_count 每维 ≤ 1`。
规模护栏:元素数 ≤ **16384** 且 `元素数 × 记录数 ≤ 32M` 单元格(防止畸形巨型数组把列表/网格撑爆)。
⚠️ `ca_type` 0/1/2 的**物理布局相同**(实测 25 个块全部是记录内连续),差别只在语义
(0 数组 / 1 缩放轴 / 2 区间轴)——所以三者都展开;`ca_type > 2` 视为未知,不展开。
167. ⚠️ **`##CA` 元素名必须按 `dim_size[]` 逐维取余**:从末维向前 `index % dim_i` 再 `/= dim_i`,
因此 1/2/3 维分别得到 `name[i]` / `name[i][j]` / `name[i][j][k]`。
⚠️ 旧实现只对「2 维」特判,3 维会退化成平面下标 `name[k]`(丢掉维度信息)。
168. ⚠️⚠️ **MF4 `cc_type = 8`(值区间 → 文本)**:`cc_val[]` **交错**存放 `(下限, 上限)`,
`cc_ref[4 + i]` 是第 i 段的文本,**最后一个引用是默认文本**(不使用)。
`cc_val_count` 必须是偶数(奇数说明表残缺,整表丢弃);`下限 > 上限` 时交换。
解析结果进 `SignalEnumText::rangeMin/rangeMax/rangeTexts`,
**只在光标取值标注**里追加 ` = <文本>`(与 `cc_type = 7` 同一出口,常规状态零变化)。
169. ⚠️⚠️ **稠密包络带的线宽必须「纵横双向」加粗**(用户 2026-10-20 报告:线宽 1px→10px 时
方波的**竖直沿**宽度完全不变):包络带每列只有 1 个 x,`top/bottom` 各外扩只能让**水平段**变粗,
竖直沿的横向宽度永远是「1 个像素列」。`EmitEnvelopeBand` 必须再做**横向窗口膨胀**
(`top` 取窗口内最小、`bottom` 取窗口内最大,窗口半径 = `ceil(halfWidth / 列距)`)——
等价于用边长 `2*halfWidth` 的方块做闵可夫斯基膨胀,竖直沿因此变成 `2*halfWidth + 列宽` 宽。
⚠️ 膨胀**只在同一段内**(`segmentColumns` 按段清空),跨 NaN 断点不得连线。
⚠️ 线宽本来就在 `PolylineCacheEntry` 的键里(2026-10-16 修过),所以这里不是缓存问题。
⚠️ 像素级验证时**不能用「隐藏信号的参考帧」做差**:隐掉唯一信号会改变网格幅值范围,
网格线与 Y 轴刻度跟着变,差分会把网格也算成波形(实测虚高到 57px)。直接匹配信号色。
170. ℹ️ **数值显示精度是 6 位有效数字**(`FormatValue` = `%.6g`),**不是**「小数点后 6 位」:
`123.456789 → 123.457`,`123.456789 ± 5e-7` 的两个值格式化后完全相同(波动在第 9 位有效数字上),
且指数 < -4 或 ≥ 6 时切成科学计数法(`0.0000123456 → 1.23456e-05`)。
想看到微小波动必须改显示格式(本轮未改,属显示策略而非数据丢失)。
171. ⚠️⚠️ **`MatHdf5::Cached(offset, bytes)` 只服务「完全落在同一个 64KB 块内」的请求**,
跨块时返回 `nullptr`。因此**绝不可以用它逐字节拼多字节标量**:`U32` 会整段读成 0、
`U64` 高 32 位读成 0,分块 B-tree 的 chunk 偏移/子节点地址被**静默**破坏(mdf-over-hdf5 类
数据看起来“缺块”)。统一走 `ScalarLE()`:命中缓存就用缓存,否则退回一次 `ReadAt`。
⚠️ 顺带这也把 `U64` 从 8 次缓存查找降到 1 次。
172. ⚠️⚠️ **MDF 的 `IsBlock()` 必须校验「链接表落在块内」**(`24 + 8*link_count <= block_len`):
否则 `DataOffset()` 会算到块尾之外,随后 `block_len - (data - offset)` 在 `uint64` 上**下溢**,
得到一个约 2^64 的“可用字节数”,下游 `vector(usable + 1)` 直接抛 `bad_alloc`
—— 一个损坏的链接数就能让加载崩溃。同理 **`offset + bytes > size` 必须写成
`offset > size || bytes > size - offset`**(接近 2^64 的偏移会让加法回绕、校验“通过”)。
173. ⚠️⚠️ **按位取出整数时不能「先拼成 64 位再整体右移」**:`bit_offset % 8 != 0` 且位宽为 64 时
需要 9 个字节(65–71 位),先拼装会让最高字节整体左移出 64 位被**静默丢弃**。
正确做法是逐字节**带着位偏移累加**(`8*i - shift` ≥ 0 左移,否则右移对齐)。
174. ⚠️ **`cg_cycle_count` 不可无条件相信**:损坏/伪造文件可以声明几十亿条记录,而
`ReadChannelGroupRecords` 会按它一次性 `assign(cycles × 通道数)` 个 double。
必须用「记录数据总字节数」做上限(压缩段按 deflate 最大膨胀比 ≈1032:1 估算)。
175. ⚠️ **CSV 的表头分支也必须跳过 `#` / `%` 注释行**:否则注释行被当成表头(列数变 1),
后面真实的表头与数据行全部按 1 列解析并被丢弃 ⇒ 整个文件加载失败。
176. ⚠️⚠️ **`SignalPanel::InvalidateRows()` 必须一并作废行下标状态**(`m_editField`/`m_editRow`、
`m_pendingCloseRow`/`m_pendingClosePart`、`m_groupNameClickRow`):行集合可能在“编辑中”变化
(加载文件 → `SyncTabsToDataSet()` → `InvalidateRows()`),不清理则回车会把新名字写到
**另一条**信号上(插入型变更让下标整体位移),✕ 也可能删错分组。
177. ⚠️⚠️ **UI 悬停态必须有唯一数据源**:`Toolbar` 曾在 `Paint` 里读 `Button::hover`,而该字段
全仓从未赋值 ⇒ 按钮永远不高亮(只写不读的“悬停变量”)。新增悬停态时先确认**读的那一份**
真的被写。
178. ⚠️⚠️ **`GdiplusShutdown` 之前必须释放所有 GDI+ 资源**,包括 `PlotView` 持有的位图
(场景/图层/装饰/面板/光晕 + 并行图层 worker 各自的位图)。`Application` 是进程级单例,
其成员要等 `WinMain` 返回后才析构 —— 那时 GDI+ 已关闭,位图析构必然访问违例。
入口:`PlotView::ReleaseGraphicsResources()`(幂等,析构函数也调它)。
179. ⚠️⚠️ **任何“时间视界写入”都必须走 `SetTimeView()`**(缩放/平移/滚轮/选区)。PAN 与
Shift+滚轮曾直接改 `ViewMin()/ViewMax()`,于是「自动点线」判据在平移时永远不重算
(从密集区平移到稀疏区也不切换),与文档承诺矛盾。
180. ⚠️ **二分得到的可见下标必须夹到 `count = min(时间轴长度, values 长度)`**:分组时间轴可能比
某条信号长(多信号共轴、有的信号短),`lower_bound` 在**整条** `axis.time` 上进行时
`begin/end` 会超出 `values` 范围 ⇒ 逐点扫描越界读。
181. ⚠️⚠️ **新增实验性功能必须同时改这 6 处,缺一个就会出现"开关点了没反应/面板不出现"**:
① `EdgePanelKind` 加枚举;② `PlotViewInternal.hpp` 的 `kEdgePanelKinds[]`(绘制与命中共用的
遍历表);③ `EdgePanelWidth` 与 `EdgePanelLayoutFor` 的 `enabled/open` 三元表达式与
`shown` lambda + `kOrder[]`;④ `PaintEdgeCursor` 里该面板的绘制分支;
⑤ `PanelSlotKey`(面板像素缓存的键,**凡影响面板文字的都要 mix**);
⑥ `PlotMenuCommand` + `ApplicationCommands` 的菜单项与派发。
⚠️ `EdgePanelLayoutFor` 里那些 `x ? ... : (y ? ... : ...)` 链很容易漏一个分支——
漏掉的后果是该功能的 `enabled/open` 恒为 false ⇒ 面板永远不显示。
182. ⚠️⚠️ **`EdgePanelWidth` 里绝不能查采样值**:它被 `EdgePanelLayoutFor` 调用,而后者在
`TopPanelAtPoint` / `SyncPanelOrder` 里被**每次鼠标移动调用数次**。若在那里逐行
`SampleDisplayedSignalAt` 算文本宽度,就变成"鼠标一动几十次采样查询",交互立刻变卡。
需要宽度时用固定常量 + 绘制侧 `UiEllipsize` 截断。
183. ⚠️⚠️ **「自由标记」的 `FreeMarkerItem::time` 是「显示时刻」**(= 画在 `TimeToX(time)` 处),
与 `TabConfig::CursorTime()` 同一口径;取值必须用 `SampleDisplayedSignalAt`
(内部减 `style.offsetTime`、处理水平镜像),**不要**直接用 `time` 索引 `axis.time`。
184. ⚠️⚠️ **「自由标记」的条目顺序只能由 `SortedFreeMarkerIndices` 决定**:
`Lane::freeMarkerItems` 保持**插入顺序**(删除某项不会打乱其它项的下标),
面板显示顺序、"前一个固定点"(增量与 Δt 的基准)都由那个函数按时间排序后得到。
绘制、命中、增量计算三处必须共用它,否则会各自排一次序而错位。
185. ⚠️ **「自由标记」超出视界的固定点直接不显示**(用户明确要求):不要复用常规数据点
那套"贴边定格 + 方向三角"的处理(`MakeDataMarker`),否则会被误认为该点在视界内。
186. ⚠️ **「自由标记」与选区放大共存的单击语义**:按下时**只登记意图**
(`m_freeMarkerPendingFix`),抬起时若"整段操作没有拖动且选区轴向未定"才固定数据点;
否则照旧进入框选。⚠️ 不能在 `OnLButtonDown` 里直接固定 —— 那样用户想框选时每按一次都会
多一个固定点。
187. ⚠️ **网格内固定点的 ✕ 命中矩形由绘制时登记**(`m_freeMarkerHits`):它依赖取值文本宽度
(而文本取决于数值),命中测试无法重新推算。与 `m_legendHits` 同一套路:
绘制时重建、`OnMouseMove`/`OnLButtonDown` 读取。
188. ⚠️ **`PaintFreeMarker` 必须画在 `PaintEdgeCursor`(浮动面板)之前**:面板在最上层,
自由标记的胶囊/✕ 不得盖住面板;接入点是 `paintOverlays()`(三个绘制分支共用)。
189. ⚠️⚠️ **`EDGE_PANEL_MOVE` 的 `switch (m_edgePanelDragKind)` 必须为每个新面板加一个 case**:
新增面板种类时若漏掉,拖动会把坐标写进 `edgePanel`,而布局又从 `xxxPanel` 读 ⇒
症状是"面板**完全拖不动**"(不是"拖动后回弹",容易误判成命中问题)。新增 `MARK` 时就踩了这个坑。
190. ⚠️⚠️ **「自由标记」默认不显示网格私有光标**:`Lane::freeMarkerCursor`(默认 false)才决定
是否画那条纵向线。默认模式下 `ProbeFreeMarker` 仅在鼠标**纵向贴近某条曲线**
(`kFreeMarkerHoverTolerance`)时有效;命中「自动点线」稀疏判据的信号还要求
`SnapTimeToSample` 成功(即靠近真实数据点)。鼠标下方没有曲线 ⇒ 探针无效 ⇒
按下就是普通平移/框选,**不会误固定数据点**(用户明确要求)。
191. ⚠️⚠️ **鼠标压在浮动面板上时必须清空自由标记探针**(`OnMouseMove` 里先 `TopPanelAtPoint`,
命中就把 `m_freeMarker` 置默认):否则光标模式下纵向光标会**穿过面板**,
且固定点的 ✕ 命中矩形可能抢走面板上的点击("面板上的操作穿透到网格")。
`OnLButtonDown` 的自由标记分支也要用同一守卫兜一层。
192. ⚠️⚠️ **面板里的"列"必须在 `EdgePanelLayout` 里给定横坐标**(`markerColumns`),
绘制侧只读不反推:这样"同一信息纵向对齐"是几何保证,而不是靠文本测量的巧合;
双击分行(编辑时刻 / 编辑 Δt)也必须用这些列边界,不能再用 `xxxRight - 40*scale` 之类的近似值。
193. ⚠️ **面板里"没有内容的条目"不要出现在布局里**:自由标记面板现在只给**确实有固定点**的
信号发名称行(`SortedFreeMarkerIndices` 为空就 `continue`),高度分支与矩形分支必须同步
跳过,否则面板会多出空白行。
194. ⚠️⚠️ **GDI 文本(`UiText` → `DrawTextW`)完全不认 alpha**:`SetTextColor` 只接受 RGB,
把 alpha 乘进颜色**不会**让文字淡出(实测 0% 时文字仍满不透明,胶囊区域像素差 300+)。
需要"文字随不透明度淡出"时只能改用 **GDI+ `DrawString`**(见 `DrawFreeMarkerAlphaText`:
由 HFONT 取 `LOGFONTW` 构造 `gd::Font(hdc, &lf)` —— ⚠️ **该构造函数没有 `Unit` 参数**)。
⚠️ 只在"确实需要 alpha"的分支改走 GDI+,100% 时继续用 `UiText`,否则整片文字的光栅化都会变。
195. ⚠️⚠️ **看不见的元素不得可点**:网格内固定点在 `freeMarkerOpacity <= 2%` 时整段不绘制,
因此也**不登记** ✕ 命中矩形(`m_freeMarkerHits`),否则用户会"隔着空气"删掉固定点。
196. ⚠️ **面板内容区必须裁剪、且只登记"与内容区相交"的行**:否则滚出视界的条目的 ✕ / 行矩形
会落在底部按钮与透明度滑块的位置上抢走点击。交互侧另有
`UiPointInRect(layout.content, …)` 兜底。
197. ⚠️⚠️ **`LanePanelState::open` 与 `collapsed` 是两件事**:`open == false` = 功能没开(面板不存在);
`collapsed == true` = 功能开着但只显示标题栏。`EdgePanelLayoutFor` 折起时仍要算完
`title / close / disclosure / badge` 再 return,否则三角指示器与 ✕ 会"消失"。
198. ⚠️ **面板的折起状态必须一起继承**:`LaneCarry`(`RebuildLanes`)与 `SplitLane` 都要拷贝
`*.collapsed`,否则勾选信号重建网格后面板会自己弹回展开态。
199. ⚠️ **`markerColumns` 是 5 列**(值 / 枚举 / 增量 / 时刻 / 间隔)。枚举列缺省时**列宽记 0**
并从排版里跳过(后面的列左移);判断"要不要这一列"只允许查 `SignalEnumText::Empty()`
(O(1)),**绝不能**逐条采样(布局函数在 `TopPanelAtPoint` / `SyncPanelOrder` 里被高频调用)。
200. ⚠️ **面板内容宽度必须给滚动条让位**:`innerWidth = 宽 − 2×pad − (可滚动 ? scrollW + 4 : 0)`,
且五列总宽 + 行 ✕ 必须 ≤ `innerWidth`(当前列宽 `{74,80,60,72,78}` + 4×2 间隙 + 13 ✕ = 391 ≤ 399)。
201. ⚠️ **MARK 面板的滚动偏移在布局里 clamp、不回写状态**(回写会让面板像素缓存键反复抖动);
滚轮与拖滚动条这两条写入路径必须自己按 `markerContentHeight − content.Height` 夹取。
202. ⚠️ **固定点取值胶囊的重叠避让只在绘制侧做**(`PaintFreeMarker` 内按已放置矩形纵向错开),
命中矩形(`m_freeMarkerHits`)由绘制时登记 ⇒ 自动跟随避让后的位置,**不要**在命中侧重算。
203. ⚠️⚠️ **实验性功能是「网格级」开关,但只能由该网格的*首个信号*继承**
(`LaneCarry::owner` + `RebuildLanes` + `SplitLane`):`applyCarry` 对
`!carry.matched || !carry.owner` 直接 **return**(保持 `Lane` 默认值 = 功能全关)。
否则「合并网格 → 拆分网格」会把一个网格的功能(以及它的逐信号条目)复制到**每一个**
拆出来的网格上(用户 2026-10-21 报告的“第二个网格也自动启用了实验性功能”)。
204. ⚠️⚠️ **任何“按信号保存的条目”在网格重建/拆分后必须按 `lane.signals` 过滤**
(`dmdetail::PruneLaneSignalItems`:`meanFilterItems` / `edgeCenterItems` / `weakPeakItems` /
`freeMarkerItems` / `freeMarkerCollapsedGroups`)。不过滤的症状:拆分后的网格里**画着别的信号的
固定点**(“野生的镜像标记点”),而浮动面板里又查不到它们(面板只列本网格的信号)。
205. ⚠️⚠️ **包络带的横向(线宽)膨胀半径必须 `floor`、不能 `ceil`**
(`EmitEnvelopeBand` 的 `radius = floor(halfWidth / step)`):`ceil` 会让 1px 线宽
(`halfWidth ≈ 0.5–0.7` 设备像素)也膨胀 1 列 ⇒ 阶跃/方波的**竖直沿 3 列宽(≈3px)**
而水平段只有 ~1.25px,用户看到“竖直跳变的线宽明显大于水平持续段”;
放大到走逐点描边路径后又正常(那条路径线宽天然一致)。修复后实测 1px 线宽:水平 1px / 竖直 1px。
206. ⚠️⚠️ **面板像素缓存:面板位置绝不要混进 `PanelGlobalKey`**。它只影响该面板自己
(`PanelSlotKey` 已混 `layout.panel/title/...`)。曾把 4 类面板的 x/y 混进全局键 ⇒
拖动任一面板都会让**所有**面板重画(15 个 ≈33ms/帧),而「自由标记」面板的位置偏偏漏混
⇒ 拖它时其它面板贴旧像素,正好在下方留**拖拽残影**。
207. ⚠️⚠️ **面板“腾出的区域”必须算脏区**(`PaintEdgeCursor` 的 `vacatedRects`):
上一帧缓存过的面板若本帧移动 / 缩小 / 关闭,它腾出的矩形仍留在**其它面板**的缓存像素里,
只靠“新矩形与谁相交”的级联是漏的(新矩形可能已不与任何人相交)。做法:绘制前预扫描
`m_panelSlots`,与本次 `EdgePanelLayoutFor` 的缓存矩形比对,不相等的旧矩形一律进
`vacatedRects`,与它相交的面板全部强制重画。
208. ⚠️ **`DoubleClickIsPlainPress` 对浮动面板默认返回 true(当作普通按下);例外是标题栏**
(排除 ✕ / GX 徽标 / 折起三角):标题栏双击有“把所属网格滚动到视界内”的语义(与单击 GX 徽标一致),
单击标题栏**不**触发联动(只是准备拖面板),折起/展开只走左侧三角。
209. ⚠️ **面板标题栏悬停保持箭头光标**(只有 `EDGE_PANEL_MOVE` 拖拽中才是 `IDC_SIZEALL`);
GX 徽标悬停同样**不改光标、不弹提示气泡**(高亮保留)。
210. ⚠️⚠️ **「自由标记」的内容(取值 / 五列文本 / 信号色)必须缓存且键**不含视界**:
`PlotView::FreeMarkerContents`(`mutable m_freeMarkerContent`)只混入条目、信号样式、
数据地址与长度。大量固定点时逐帧 `SampleDisplayedSignalAt` + 拼字符串是“拖全局光标 / 平移变卡”
的主因;平移视界、拖光标时缓存必须命中(键里加 ViewMin/ViewMax 就等于没缓存)。
211. ⚠️ **「标记透明度」滑块与光标模式按钮解耦**:滑块行**始终**显示在按钮上方
(`kFreeMarkerSliderRowHeight`,面板高度算式同步);鼠标悬停在某个取值胶囊上时该胶囊恢复
全不透明,但若探针正瞄着**被该胶囊遮住的数据点**(`m_freeMarker.probe` 落在胶囊内)则不恢复
—— 否则无法看出自己瞄到了哪个采样点。
212. ⚠️⚠️ **面板里的数据列不得出现省略号**:五列宽度取「缺省列宽」与
`Lane::freeMarkerColumnWidths`(绘制该面板时把真实文本量一遍存回 `Lane`)的**较大者**,
面板宽度随之撑开;`DT_END_ELLIPSIS` 对“刚好装满”的文本仍可能截断,因此每列额外留 2px 余量。
网格内取值胶囊同理(宽度额外留 12px 余量)。
213. ⚠️ **「自由标记」面板每个展开分组下必须有一行列表头**
(`MarkerGroup::tableHeader` + `kFreeMarkerHeaderRowHeight` + `kFreeMarkerColumnLabels`),
与数据行共用 `markerColumns` 的列边界 ⇒ 表头与数据严格对齐;列宽还要 ≥ **列标题宽度**。
表头行高度必须计入内容总高(`totalContent`)与面板高度(`EdgePanelWidth`)。
214. ⚠️ **枚举列缺省只由 `SignalEnumText::Empty()` 决定**(O(1),不逐条采样):
所有带固定点的信号都没有枚举表时,枚举列宽记 0、列标题也不画。
215. ⚠️⚠️ **吸顶是“每个元素各自”的**:`y = max(自然, min(吸顶, 本组下边界 − 自身高度))`。
分组栏吸到内容区顶、表头吸到分组栏下方 ⇒ 组末尾时**表头先被推走 → 条目 → 分组栏最后离场**
(用户 2026-10-22 明确要求的次序)。**不要**把它们当成一个整体块一起吸顶
(那样两者会同时离场,不符合要求)。
⚠️ 只有一个分组且内容超高时分组栏会一直钉住,这是数学上正确的(组内容还没滚完)。
216. ⚠️⚠️ **MARK 面板的绘制顺序必须是「条目 → 表头 → 吸顶分组栏」**:否则向上滚动的条目会盖住
吸顶元素。分组栏底衬必须**完全不透明**(先铺信号色、再叠 `WithAlpha(signalColor,0xE8)`),
表头底衬同样不透明;被吸顶区域盖住的条目由 `coveredByPinned` 同时从绘制与命中里剔除。
217. ⚠️⚠️ **行选中与「✕ 悬停」是两套状态**:`Lane::freeMarkerSelected`(选中行,`PanelSlotKey`
必须混入)+ `m_freeMarkerRowHoverLane/Item`(整行悬停)。整行悬停时**不能**让该行的 ✕
变成 danger 色(那是“准备删除”的语义),但要把网格内对应的固定点整体高亮
(数据点 accent 圆环 + 胶囊强制不透明 + 2px accent 描边)。删除固定点时必须校正选中下标。
218. ⚠️ **固定点取值胶囊**原位显示**、不做重叠避让**(用户 2026-10-22 明确要求恢复初版方案:
允许遮挡)。2026-10-21 的纵向避让(`placed/occupied`)已删除,不要再加回来。
219. ⚠️⚠️ **非光标模式的探测判据 = 鼠标到「画出来的折线」的垂直距离 ≤ `kSignalHitTolerance`**
(与常规触发曲线高亮**完全同一范围**)。实现复用 `AcquirePolyline` 顶点 + 窗口二分 +
点到线段距离(散点模式比顶点)。旧实现只看“同一横坐标的采样值之差” ⇒ 阶跃的**竖直段**
永远判不中(两端采样值是 ±1,都不是鼠标高度),用户既看不到预览也无法在跳变线上固定点。
⚠️ 命中后时刻要**吸附到最近采样时刻**(非光标模式恒吸附;光标模式仍只在稀疏判据成立时吸附),
这样固定点一定落在真实数据点上。`kFreeMarkerHoverTolerance(8)` 已删除。
220. ⚠️⚠️ **面板内的文字一律用 `UiTextClipped(..., layout.content)`**:GDI `DrawTextW`
**不认 GDI+ 裁剪**,滚动时“部分可见”的条目会把文字画到标题栏上(用户 2026-10-22 报告)。
这条同时适用于分组栏名称/计数、表头列标题与空内容提示。
221. ⚠️⚠️ **目录约定:`Inc` 只放头文件、`Src` 只放 `.cpp`**(含 `*Internal.hpp` —— 它也是头文件)。
历史上 `*Internal.hpp` 放在 `Src` 也能编译,只是因为 `#include "..."` 会先找源文件所在目录;
一旦文件被搬走就无声失效。所有 include 都是扁平形式,靠 `-I.../Inc` 解析 ⇒ **搬家不要改 include**。
⚠️ `Makefile` 里 `CXX_INCLUDES_PATHS` 前的注释块必须单独成行(写成同一行会把整个变量注释掉,
症状是 `fatal error: CsvLoader.hpp: No such file or directory`)。
222. ⚠️⚠️ **未开启「自由标记点选取」时鼠标就是普通鼠标**(用户 2026-10-23 要求):
`ProbeFreeMarker` 必须 `if (!lane.freeMarkerCursor) return probe;` 早退 ⇒ 不预览数据点、
不画网格私有纵向光标、`OnLButtonDown` 不登记固定意图、单击也不固定。
⚠️ 开启后纵向光标**整列都在**(`probe.valid` 恒为真),因此“能否固定”只看 `probe.ref.IsValid()`:
`ProbeFreeMarker` 里必须有 `if (!hitCurve) continue;`(否则 `ref` 一直有效,
鼠标在全网格任意位置单击都能固定出一个离曲线很远的点)。
223. ⚠️ **「显示增量」「显示枚举文本」两个开关只影响已固定点的胶囊文本**
(`Lane::freeMarkerShowDelta/freeMarkerShowEnum`,默认 `false`,`LaneCarry` 双向携带):
跟随鼠标的预览胶囊恒为 `FreeMarkerValueText(value,false,false,0,true,enumText)`(值 = 枚举);
面板统计列也不看这两个开关。开关行独占底部最后一行、左右各半宽,与主按钮间距 `kFreeMarkerButtonGap`,
`switchRow` 必须计入面板高度**和**内容区高度。
224. ⚠️ **面板列序固定为 `序号 / 标记值 / 枚举文本 / 增量 / 时间点 / 时间间隔`**(六列):
序号列**恒存在**(`MarkerRow::serial` 组内 1 起);枚举列只在存在枚举表时出现,出现后
**无枚举/对不上任何枚举项一律显示 `N/A`**;列与列之间必须画竖线分隔。
任何“列数”都散落在 `markerColumns`、`kFreeMarkerColumnLabels/Widths`、`Lane::freeMarkerColumnWidths`
与各处 `column < N` 循环里 —— **改列数必须一次性全改**。
225. ⚠️ **悬停面板某行时,网格内其它固定点的不透明度封顶 50%**(原本 ≤ 50% 的不变;目标行强制 100%),
移出立即恢复;命中也必须按压暗后的 alpha 判定(否则“看不见还能点”)。
226. ⚠️ **固定点 ✕ 的悬停浮动提示已删除**(`PaintFreeMarkerTooltip` 实现与声明均已移除,不要加回来);
该网格开启「自由标记点选取」时,信号悬停提示只显示信号名(不再提示“可按住拖拽”)。
227. ⚠️⚠️ **`FreeMarkerContents` 的内容缓存键必须混入枚举表指纹**
(`values/texts/rangeMin` 规模 + `texts.data()` 地址):只混信号指针与采样数量时,
给信号加上枚举表后条目里仍然是 `N/A`(离屏探针实测复现)。
228. ⚠️ **`PanelSlotKey` 必须混入 `layout.showDelta/showEnum` 与两个开关布尔值**:
否则点击开关 / 布局变化后会把旧像素贴回来。
229. ⚠️ **固定点必须落在真实采样时刻上**:`ProbeFreeMarker` 命中曲线时**始终**吸附到最近采样点
(`snapToNearestSample`);只有“已吸附”圆环(`probe.snapped`)仍只在稀疏判据 `sparse` 成立时才显示
(密采样时吸附量 < 1 像素,圆环常驻会像是画错了)。
230. ⚠️⚠️ **浮动面板必须消费右键 / 中键**:`PlotView::OnRButtonDown` 用 `TopPanelAtPoint` 判定,
命中即 `BringPanelToFront` 并返回 true(宿主**不得**再弹网格的 Y 轴菜单);`OnMButtonDown`
在面板上**不得**进入平移(`m_dragMode = NONE` 后消费)。层序与左键 / 双击完全一致。
⚠️ 任何“上层元素”都必须在宿主分发前判定,否则会“在面板上右键却弹出下面网格的菜单”。
231. ⚠️⚠️ **「自由标记点选取」的探测没有“必须贴近曲线”的门槛**(用户 2026-10-27 明确要求):
总是预览**网格内离鼠标较近的那个信号**的数据点(同一时刻下纵向更近者优先)。
实现 = 两个候选取更近:① 鼠标到画出来的折线的垂直距离(阶跃竖直段只有它能命中);
② 同一时刻(鼠标时刻吸附到最近采样)该采样点的**纵向距离**。旧实现 `if (!hitCurve) continue;`
⇒ 网格里只有一条信号且鼠标离曲线较远时既无预览也无法单击固定。
232. ⚠️⚠️ **固定标记点必须位于 GX 编号徽标与图例“之下”**:做法是把 `m_legendHits` 的托底矩形与
`LaneBadgeRect` 全部 `gd::Region::Exclude` 出绘制区(`PaintFreeMarker` 开头),
**不要**改用“画完标记点再把图例重画一遍”—— 重画的抗锯齿边缘与装饰缓存 `AlphaBlend`
的结果不完全一致(实测图例胶囊圆角处仍有 240 像素差)。
⚠️ **GDI 文字不认 GDI+ 裁剪**:胶囊文字与遮挡矩形相交时必须走 GDI+(`PaintFreeMarkerCapsule`
的 `vectorText` 参数),否则文字会“穿透”到图例上面。
233. ⚠️⚠️ **预览曲线的取值必须按时间在相邻采样点之间插值**,且插值要在**套用「调整」变换之后**
(`PaintDerivedCurve` 逐顶点 `ApplyDrawTransform` 后用直线连接;绝对值/垂直镜像不是线性的)。
只取“最近采样点”的曲线值会让标注点离开曲线(用户 2026-10-27 报的“预览曲线数值点悬空”,
实测阶跃区偏 36~39 像素)。`probe` / 中心轴 / 均值滤波 / 弱峰调整共用 `paintDerivedCapsule`。
234. ⚠️ **产物体积红线已放宽到 900KB**(用户 2026-10-27):**到达 900KB 必须提醒用户**,
以便确认功能带来的体积膨胀是否值得;这不是“不许超过”,旧 800KB 红线作废。
235. ⚠️⚠️ **新增 UI 基础层的位置与边界**(2026-10-28,`UI_Refactor`):
`Components/Shared/{Inc,Src}/UiLayout.*`(纯几何 + 布局缓存)与 `UiWidget.*`(控件树基类)。
它们**不依赖任何功能组件**(只有 `Theme.hpp` / `UiLayout.hpp`),符合既有的
`Components/* → Components/Shared → Utilities` 依赖方向。
⚠️ 新增 `.cpp` 放在 `Components/Shared/Src` 即可,**无需改 `Makefile`**(按 basename 自动收集)。
236. ⚠️⚠️ **`UiWidget` 的事件语义:只沿命中链派发,绝不分发给同层兄弟**。
`Dispatch` 先判 capture(拖拽期事件直投捕获者)→ 再判 modal(只向模态子树派发)→
位置事件沿"最深命中者 → 祖先"冒泡(命中者优先,未消费才交给祖先作兜底);
键盘/字符沿**焦点链**(无焦点交给根,保证宿主级快捷键仍可用)。
早期实现"遍历同层兄弟、未消费就继续" ⇒ **一次点击可能被视觉上位于下方的控件也处理**(已修)。
237. ⚠️⚠️ **`UiWidget::Paint` 的三条契约**:
① `ctx.dirty` 为空 = 脏区未知 ⇒ 全画;非空 ⇒ 只保证该区域被更新;
② 脏区与子树矩形**无交集 ⇒ 整棵子树直接 return**(这是框架里唯一真正省时间的机制,
禁止任何派生类退化成"父脏 ⇒ 子树全脏");
③ 有效裁剪 = 祖先裁剪切 ∩ 自身矩形 ∩ 脏区;**GDI 绘制不受该裁剪约束** ⇒
派生类必须把 `ctx.clip` 传给 `UiTextClipped` 一类带 `ETO_CLIPPED` 的辅助函数。
238. ⚠️ **`UiLayoutCache` 用 `std::deque` 保证"`Store` 之后已有引用仍有效"**;
淘汰按"最久未被命中";`Invalidate` 必须是**定向**失效(与 `m_edgeScans` 同一纪律),
**禁止**为了让命中率好看而整体 `Clear()`。
239. ⚠️⚠️ **`PlotView` 实现分片已按"关注点"拆分(七个文件)**,不再按函数名前缀:
`Paint 4.9k / Interaction 3.4k / Features 3.3k / Raster 1.9k / Panels 1.4k / Core 0.9k / CacheKey 0.5k`
(`PlotViewPaint.cpp` 由 9 080 行降到 4 913 行)。硬约束:
* `PlotView::Paint` / `PaintLane` **只允许**定义在 `PlotViewPaint.cpp`;
* 像素缓存与每帧预算在 `PlotViewRaster.cpp`;内容指纹/缓存键在 `PlotViewCacheKey.cpp`;
面板布局与自由标记几何/探测在 `PlotViewPanels.cpp`;
* 机械搬移的判据是**逐像素 A/B 哈希一致**(不是"能编译")。
⚠️ 搬移后若用旧文件回退做 A/B,**必须先删掉 `Build/<Config>/` 里对应的陈旧 `.o`**,
否则链接会因重复符号失败。
240. ⚠️ **回归网已入库**:`Tools/uicheck`(`build.ps1` 链接 `Build/<Config>/*.o`,排除 `Main.o`)。
改 UI 基础层或迁移组件前先跑它(当前 62 项断言);A/B 校验时把它当"判别力证据"。
### 11.3 离屏验证方法学(本机偏好,强烈建议沿用)
- **不要占用用户的鼠标 / 键盘 / 屏幕**:优先 `.preflight/uitest`(离屏 harness:`SW_HIDE` 窗口 + 直接注入消息 + `BitBlt` 从 `m_backDc` 截图)或 `.preflight/perf`(离屏计时 / 逐像素对比)。
`act.ps1` / `real.ps1` 会 `SetCursorPos` / 抢焦点 / 钉窗口,**仅当缺陷与捕获 / 焦点强相关且无法离屏复现时**才用。
- 验证"某交互不应改变外观"用**像素哈希 / 逐像素比对**(`hashregion.ps1`、`compare.ps1`),**只对不受该交互影响的区域**取样。
- 测耗时用 `QueryPerformanceCounter` 打点并**批量写日志**(探针里每帧 `fopen/fprintf` 会给单帧凭空加几十毫秒)。
- 大文件压测用 `TestData/VERY_BIG_DATA.CSV`(1.77GB / ≈2580 万采样 / 5 信号);计时前先把视界**精确复现**到被测区间。
- `.preflight/` 已被 `.gitignore` 忽略;探针与脚本不要入库。
- PowerShell 终端:多行粘贴不可靠(写成 `.ps1` 再执行);`git commit -m` 多行会被截断(改用 `git commit -F 文件`);终端里**不要写非 ASCII 字面量**(会被二次编码成乱码),中文一律用文件编辑工具写入。
---
## 12. 起源设备详细工程记忆(原文附录)
> ⚠️⚠️ **时效声明**:本节是**历史原文**,成文于数据层大重构与后续多轮修改**之前**。
> 其中出现的 `DataFile` / `fileName` / `SignalData::displayName` / `Title()` /
> `SetSignalDisplayName` / `SetFileDisplayName` / `PaintFileRow` / `FileCloseRect` /
> `FileGroupLabelWidth` 等**符号已全部更名或删除**,请勿照抄。
> 现行约定一律以 **§1–§11** 为准(§11.2 是硬约束权威清单)。
> 以下为起源设备(本机)Agent 的**详细工程记忆原文**,与 §1–§10 同处**一个记忆文件**(不再另建文件)。
> ⚠️ 其它设备更新本文件时**请保留本节**;可继续在本节之后追加。
**§12 阅读指引**
- §1–§10 是跨设备共享的**精简权威备忘**(项目目标、结构规范、性能架构演进);§12 是本机视角的
**详细现场经验**(逐项功能约定、UI / 交互硬约束、验证脚本与坐标、Git / 终端陷阱)。重叠处以 §1–§10 为准。
- §12 内的小节编号(如 `## 架构`、`## 性能铁律`、`## 验证手段`)自成一套体系,与 §1–§10 的编号无关,按标题名理解。
- §12 内容 = 本机 Agent 的仓库记忆原文(`/memories/repo/just-datainspector.md`),会随本机工作持续更新。
- 本机工具链与高风险硬约束摘要见 **§11**;§12 开头的「架构已变更」是**合并远端 `f97c4cf` 后的新架构**(最优先阅读)。
## ⚠️⚠️ 架构已变更(2026-10-12 合并远端 `f97c4cf`):**先读这一节**
> 本仓库现有跨设备共享记忆 **`Documents/agent_mem.md`**(另一台设备维护、已入库)。接手前先读它。
> 本节记录合并后**本机必须知道的新架构**;下面旧章节里"静态场景缓存是唯一快路径 / 只有一张全局宽缓冲"的描述**已过时**。
> 合并方式:`git merge --ff-only gitea/main`(远端 **16** 个提交线性追加在本机 `7608e85` 之后,合并后 HEAD = `f97c4cf`)。
### `Paint()` 分支顺序(硬约束,勿改序)
`LANE_REORDER 条带重排` → `静态场景缓存贴图` → `每网格信号图层(宽基底)` → `样式补丁` → `整幅重绘`。
- 场景贴图必须在**图层之前**:视界/样式都没变时(典型「鼠标悬停移动」)必须命中贴图,
否则每两帧触发一次「结算整幅重绘」,悬停反而更卡。
- 图层必须在**样式补丁之前**:拖弱波增强/均值滤波参数只改派生曲线,走图层比逐网格补丁更快、质量更高。
### 每网格「信号图层」(取代原先唯一的那张全局宽缓冲区)
- `LaneLayer`(`PlotView.hpp`):`m_layerFront` 前台只读 / `m_layerBack` 后台填充,**双缓冲**;
`m_layerWorker` 后台线程(`THREAD_PRIORITY_BELOW_NORMAL`)逐网格重画,画完 `std::swap` 交换。
- **图层只含基底**(底色 + 网格线 + 波形 + 派生曲线),**不含装饰**(Y 轴刻度 / 图例)——
装饰锚定网格左缘、会随平移重影,改由「装饰层缓存」在前台绘制。
- 位图宽度 = `plotWidth + 2*max(1, plotWidth/2)` ≈ **2 × plotWidth**(左右各半屏,可平移 ±半屏)。
⚠️ 头注释与提交信息写「宽 ×3」是**错的**(代码就是 ×2)。
- `RenderLaneLayerRegion(laneIndex, layer, stripX, stripW)`:临时改写 `m_area/m_plotRect/ViewMin/ViewMax`
后用与整幅**完全相同**的 `PaintLane` 渲染;`m_renderWide=true` 跳过派生曲线与装饰;
**强制 `m_fastFrame=false`(全质量 AA)**;竖网格刻度沿用**前台视界跨度**(否则竖线只剩 1/3)。
⚠️ **结束后必须 `BuildLaneVisuals()`**(重算 `m_laneContentHeight`)**再**还原 `LaneScroll` 与各网格
`yMin/yMax`;少还原任何一项 → 前后台指纹永不相等 → 图层永远"过期" → 每帧重复排任务 → 界面卡死。
- `RollWideBuffer()`:视界移动时整数像素**自贴图平移** + 只补画新暴露的细条(每帧 O(细条),无周期性尖峰)。
- `LayersReadyForView()` **不比较内容指纹**(只要求跨度一致、能覆盖视界)→ 前台「内容稍旧也照用」;
不一致的网格由 `ScheduleStaleLayerRenders()` 交后台补画(按 `laneIndex` 去重);
`m_layerRescheduleStreak > 90` → 同步 `RebuildWideBuffer()` 强行收敛(防死循环)。
- 加锁顺序**恒定** `m_renderMutex`(recursive)→ `m_layerMutex`;反序 = AB-BA 死锁。
后台「前台优先让路」:`GetTickCount64() - m_lastPaintTick < 30ms` 就等 8ms 再重排队。
- 后台完成后 `PostMessageW(host, kMessagePlotDerivedReady)`;`Application` 只 `InvalidatePlotRect`,
且该消息**不置** `m_chromeDirty`。
- `BackgroundLayerRenderCount()` = 公开只读计数(验证后台确实在画)。
### 「结算」帧(保证最终像素精确)
`wideKey = SceneContentHash(true) ⊕ llround(ViewMin*1e6) ⊕ llround(ViewMax*1e6)`(FNV 混合);
若与上一条快路径帧相同 → 判定视界已停 → 放弃快路径、落到**整幅重绘**一次全质量精确帧。
(图层按整数像素平移,存在 ≤0.5px 亚像素偏差,必须有这一次结算。)
### 指纹(三套,勿混淆)
- `SceneContentHash(includeStyles=true, includeDerived=true)`;
`SceneGeometryHash() = SceneContentHash(false)`(含派生参数、**不含**逐信号外观);
`SceneDecorHash() = SceneContentHash(true, false)`(含外观、**不含**派生参数 → 拖参数滑块不使装饰失效);
`LaneLayerKey(i) = SceneContentHash(true,false) ⊕ (i+1)*k ⊕ span ⊕ plotRect.Width`。
- ✅ **`Tab().LaneScroll()` 已进指纹**(本轮 bug 根因之一:不进指纹 → 滚网格时把旧位置像素贴回 + 误触发结算)。
- `FindStyleOnlyLanes()` 现在**额外逐项比较 `SceneCacheKey`**(视界/滚动/布局/缩放/光晕)——
`SceneGeometryHash()` 刻意不含视界,只比它不足以判定"视图没变"。
### 装饰层缓存 `m_decorDc`(Y 轴刻度胶囊 + 图例)
- **每一帧"真正绘制了装饰"都要 `CaptureDecorCache()`**(不再只在全质量帧捕获);
同时保存 `m_decorCapturedRects`(矩形列表)与 `m_decorOriginX/Y`(`m_area` 原点),
贴回时**必须用保存的那一份**(命中缓存时本轮不会重建矩形列表 → 用当前列表会贴空/贴错)。
- 装饰底板**已改为不透明**(原 94%/91%)→ 装饰像素与时间视界无关,可安全跨视界复用。
### 派生曲线的后台计算(另一套线程,与图层线程独立)
- `m_curveWorkers` 线程池(线程数 `clamp(硬件并发-1, 1, 4)`,BELOW_NORMAL);`m_curvePins` 钉住本帧用到的条目。
- **`PauseDerivedAsync()`** 必须在任何"会移动/释放采样"的操作前调用:
`Application` 的 `RemoveFile` / `RemoveSignal` / 加载线程完成后 `AddDataFile` / 插入派生信号。
- 预览曲线一律按**整条信号**计算(与「构建为新信号」逐位一致)。
- 大窗口缓存不再按采样数排除(`kCurveSpanCacheMaxSamples` 提到 64M),改用总字节预算(≈768MB)+ 条目数上限淘汰。
### 浮动面板像素图层(7608e85 已有,合并后仍在)
`m_panelDc` + `m_panelSlots`;`PanelGlobalKey()`/`PanelSlotKey()`;**滑块数值只对 BOOST 计入面板键**;
某个面板重画后,**所有"在其后绘制且与之重叠"的面板也必须重画**(级联 `dirtyRects`)。
### 随同合并上来的其它修复
- `PopupMenu::OnMouseMove`:子菜单展开期间,指针仍在「主菜单 ∪ 子菜单」内且未落到其它可点击条目 → **不收起**
(修「移向二级菜单途中被误关闭」,CPU 快 / 鼠标事件密集时必现)。
- `Application` 在绘图区右键弹网格菜单前调 `m_plot.OnMouseLeave()` 清悬停;
`PlotView::OnMouseLeave()` 补清 `m_hoverLegend`(修「菜单打开后光晕/提示气泡残留」)。
- `DataModel`:`ComputeWeakWaveBoost` / `ApplyWeakBoostStage` / `ComputeMovingAverageIterated` 的工作缓冲改
`thread_local` 复用;`ApplyWeakBoostStage(input, strength, output, extremes)` 多一个 `extremes` 出参。
- ⚠️ **另一台设备的工具链不同**(MinGW-Builds 16.1.0 seh-rev0);本机是 GCC 15.2.0 UCRT。
**Makefile 未变**(仍 `-lgdiplus -lcomdlg32 -ld3d11 -ldwmapi -ld3dcompiler`),本机 `make release` 已验证通过。
---
## ⚠️ 性能铁律(b960b03 实测,务必先读)
**本项目的性能瓶颈几乎总是「GDI+ 抗锯齿矢量绘制在复杂路径上耗时爆炸」,而不是 CPU 逻辑。**
排查这类卡顿的顺序:① 先写离屏探针量化(`Application::Render()` 直接调用 + QueryPerformanceCounter
计时;`#define private public` 后可直接调 `BuildPolyline`/`PaintLane`/`PaintDerivedCurve`);
② 再对症把"复杂路径"换成"廉价表示"。
- **包络带必须做 ±1 列极值扩展**(`EmitEnvelopeBand`):信号振荡周期与像素列宽相当时
(编码器方波 2ms/大格 ≈ 3 采样/列),逐列包络会在"全高/极窄"间交替 → 1224 列里 988 处
全高跳变 → GDI+ 扫描线填充 **27.6ms**;±1 列扩展后 **1.1ms(25 倍)**,纵向仅多 1px。
⚠️ 这是"某些放大等级下平移特别卡"的根因(2ms/大格 87ms vs 其它等级 17~20ms)。
- **派生预览曲线(均值滤波/弱波增强)稠密时必须走逐像素列包络带**,不要用抗锯齿折线:
折线在方波上实测 12~42ms/条(5 网格上百毫秒);包络带 FillPath 0.56~1.53ms。
- **悬停/拖拽光晕在包络带模式下用"带外扩填充"**(`PaintBandHalo`),不要用宽笔描折线(+35ms/帧)。
- `UiMeasureTextWidth` 已按 (字体,文本) 记忆化 —— 布局代码每帧会重复测量同一批字符串
成百上千次(浮动面板几何按「网格数 × 面板种类」重算),**新增布局代码不要绕过它**。
字体重建时必须 `UiClearMeasureCache()`(句柄可能被系统复用)。
- 实测(VERY_BIG_DATA 5 信号全选,plot 全重绘/帧):20ms/大格平移 20.1→11.3ms;
2ms/大格 86.7→12.3ms;弱波增强 1 个网格 63.9→17.6ms、5 个网格 136.6→31.9ms。
- ⚠️ **已知未解热点**:0.5ms/大格附近(可见约 1000 采样、走逐点描边路径)平移仍 28~39ms。
该区间采样比像素稀疏,无法用包络带替代;用户明确要求保留抗锯齿,故未动。
- 探针脚本模板(用完即删,`.preflight` 已 gitignore):`.preflight/perf3/{main.cpp,build.ps1}`
= 加载大文件 + 分项计时(Render 缓存命中/整帧、平移、BuildPolyline、PaintLane、派生曲线)。
## 越界三角样式(b960b03)
- `kMarkerTriangleGap = 4`:方向三角与数值/时间标注之间**必须留出该间隙**,
否则三角看起来像胶囊上多出的尖角。
- 左右越界的取值标注用 `marker.x ± kMarkerTriangleGap` 定位(不再用 pinX±radius)。
- **光标时间读数与 Δt 读数现在与网格内数据点同一套样式**:三角在胶囊**外侧**、尖端朝外;
为此 `CursorLabelRect` 在越界一侧把胶囊向内让出 `kMarkerTriangleDepth + kMarkerTriangleGap`。
`kCursorTipStackGap` 已删除(那套"三角压在胶囊上沿"的画法被用户否决)。
- 验证手法(`[AD]`):探针点/颜色要能区分"三角"与"胶囊"——三角的 AA 边缘会污染胶囊色,
所以改测 ① 胶囊左边界 vs marker.x 的数值关系、② 胶囊上方区域无墨迹、③ 用散点模式
让背景露出以便逐像素找到胶囊左边界;断言"间隙恰好等于 kMarkerTriangleGap"。
## 「迭代次数 N」(b960b03,原「阶数 N」)
- 用户要求:实验性功能里的「阶数 N」一律叫「迭代次数 N」;标识符/形参 order → iterations。
- 导出信号尾缀**恒定带迭代次数**:`<原信号> (N点均值 M次迭代)` / `<原信号> (弱波增强 X% M次迭代)`
(弱波增强此前缺失该信息)。⚠️ 网格拖拽重排里的局部 `order` 已改名 `laneOrder`,
别把 `std::memory_order_*` 一起改掉。
## 本轮:弱波增强去前置滤波 + 阶数 N;选区越界绘制;面板 ✕(提交 8c8508e)
- **弱波增强不再做前置滤波**(删除 BoostShapeAt 的 5 点滑动平均):极值直接由输入采样
的方向翻转判定。用户需要平滑时先用「均值滤波」构建滤波后的信号再对它增强。
⚠️ 副作用(已在真实 20K 录波上实测):数字开关通道这类带毛刺的信号会被大量"弱波化",
总变差 195→1486;电流纹波通道 3882→6416(N=1)/8699(N=5),但值域始终不超出原范围。
- **新增「阶数 N」(1–10,滑块)**:语义同均值滤波的阶数 —— 把 `ApplyWeakBoostStage`
重复应用 N 次,每阶以上一阶为输入;某一阶若无任何改动则提前结束。
`ComputeWeakWaveBoost(values, level, order, out)`;`Lane::weakBoostOrder`(默认 1)。
实测三连弱波(600,550,550,550,600):外侧峰 550→575(N=1)→598.4(N=10),
中间峰 550→550(N=1)→575(N=2)→598.4(N=10);健康峰在任何阶数下逐位不变。
→ 阶数是"让修正向弱波区内部传导"的手段,不是让单峰超过等级目标值。
- 面板新增第二行滑块(`EdgePanelLayout::sliderOrder`、`PlotDragMode::BOOST_ORDER`);
`EdgePanelWidth` 取两个标签/数值的较宽者;高度 = 标题 + 条目 + **两行滑块** + 按钮。
构建命名 `<原信号> (弱波增强 60%×2阶)`。
- **选区放大越界绘制**:鼠标被捕获到绘图区外时 `m_rubberRect` 会用原始客户区坐标 →
选区画到工具栏/面板上,而拖拽期间只失效绘图区 → 留下擦不掉的墨迹。
修法:OnMouseMove 里把 x/y 夹在 m_plotRect(纵向夹在按下时的网格)内,
**PaintRubberBand 再对 m_plotRect 求一次交**(两道保险)。
验证手法:同一鼠标位置的"按下/未按下"两帧,绘图区之外像素差必须为 0
(探针点要选在绘图区左留白,那里没有任何悬停反馈;选信号面板会因面板行悬停而误报)。
- **浮动面板 ✕**(三个缺陷一起修):
1. `layout.close` 在 `layout.title` 矩形**内部**,按下 ✕ 后会继续落到
「标题栏 → 拖动面板」分支 → "按住 ✕ 却拖走面板"。→ 按下 ✕ 后**直接 return true**。
2. ✕ 悬停不显示高亮:✕/滑块/按钮的高亮都靠 `m_mouseX/m_mouseY`,但从同一网格的波形
移到面板上时 `m_hoverLane` 不变 → `OnMouseMove` 返回 false → 宿主不重绘。
→ 新增 `m_mouseOverPanel`,`(overEdgePanel || m_mouseOverPanel) && mouseMoved` 时强制返回 true。
⚠️ 离屏 harness 直接调 `app.Render()`,**测不出"没有请求重绘"这类缺陷** →
必须直接断言 `plot.OnMouseMove(x,y) == true`。
3. ✕ 的光标改为 `IDC_HAND`(与其它 ✕ 一致)。⚠️ 早期记忆里"面板 ✕ 不改光标"已被用户推翻。
- harness 维护提示:`.preflight/uitest/main.cpp` 曾被外部工具**双重行距化**
(每行后插一个空行,注释头也被搬动);用 Python `text.replace("\r\n\r\n","\r\n")`
一次即可精确还原。改测试前先 `git ls-files --eol` 之外的字节级检查确认行尾。
- 合计 **345 项检查全部通过**。
- 目标:纹波里“比左右同类极值更弱”的波峰/波谷被拉回周边振幅;健康周期逐位不变。
- 步骤(全 O(n)):① 用 **5 点滑动平均**(就地按需算,不分配与采样等长的数组)看“形状”,
扫描单调段,方向翻转处的**上一个采样点**即为极值(波峰/波谷必然交替);
② 波峰在 `self < (left+right)/2` 时判为弱波(波谷为 `>`);
③ 目标值:波峰 `min + level×(max−min)`、波谷 `max − level×(max−min)`
(left/right 取**列表下标 ±2 的同类极值**;level 0–1,超出夹取);
④ 偏移施加:极值点自身取其 shift,**相邻两个极值点之间**用 `t²(3−2t)` 平滑步进
把两端 shift 过渡开;首尾段从 0 渐变,保证数据端点原样不动。
- ⚠️ **8c8508e 起已删除 5 点前置平滑**(按用户要求"不做前置滤波",用户自己用均值滤波)。
下面这条旧结论已被推翻,仅留作历史:~~不要用原始采样直接判方向~~ ——
现在就是直接用原始采样判方向;带毛刺的信号会被大量判成弱波(这是刻意行为)。
- ⚠️ **±2 索引要求极值序列交替**,因此列表最前面 2 个与最后 2 个极值不参与判定。
构造测试数据时必须留够 knot 数(曾因 knot 太少使目标弱峰没有左邻居 → 算法完全不生效)。
- ⚠️ 极值检测要精确落在采样点上,只有**平滑前后对称的纯正弦**才保证;
smoothstep(两侧曲率不同)可能偏 1 个采样 → 断言留 ~0.05 容差或改用正弦。
- 实测(P=128、A=100、按周期调幅 [1,1,0.5,0.6,1] 的合成正弦):
弱峰 550 →(等级 0/50/100%)560/580/600;弱谷 450 → 440/420/400;
弱区(±它的两个相邻极值跨度)之外逐位不变;常量信号原样返回。
- 缓存 `m_weakBoostCurveCache`(键 = 信号指针 + 等级百分比);`Lane::weakBoostLevel` 默认 0.5。
### 弱波增强面板(`EdgePanelKind::BOOST = 3`)
- `Lane` 新增 `weakBoost / weakBoostLevel(0–1) / weakBoostItems`(`WeakBoostItem = MeanFilterItem` 复用)
`/ weakBoostPanelOpen/X/Y`;**`LaneCarry`、`SplitLane`、`RemapAfterSignalInsert/Removal` 都要同步**(等级也要带)。
- 布局:标题 + 每个可见信号一行(名称 + 复选框)+ **一行等级滑块**(`layout.slider` = 轨道矩形)
+ 底部「构建为新信号」。`EdgePanelWidth` 为滑块行加宽度下限(标签 + 轨道 120 + 「100%」);
高度 = 标题 + pad + 条目 + 滑块行 + pad + 按钮 + pad。
- 滑块命中用匿名命名空间 `WeakBoostSliderHit(track, scale, x, y)`(轨道上下各放宽 ≥8px);
拖拽 = `PlotDragMode::BOOST_LEVEL` + `m_boostDragLane`(OnLButtonDown 起点 / OnLButtonUp 复位),
OnMouseMove 用 `UiSliderRatioFromX` → `SetLaneWeakBoostLevel`(钳 0–1 + 清曲线缓存 + 失效场景缓存)。
- `SceneContentHash` 必须 mix `weakBoost` / `weakBoostLevel` / 每个条目 ref。
- 构建:`PlotView::WeakBoostBuild{fileIndex,signalIndex,levelPercent}`;
`Application::DerivedSignalRequest{fileIndex,signalIndex,points,order,levelPercent,weakBoost}` +
`BuildDerivedSignal(request, makeVisible)`;命名 `<原信号> (弱波增强 M%)`。
- 光标胶囊尾缀「弱波增强」;`paintDerivedCapsule` 已改为**直接收曲线引用 + 尾缀**
(中心轴 / 均值滤波 / 弱波增强三者共用,不再传 first/second/diffAmp)。
### 拖拽光标越出视界(再次出现,已修)
- 根因:`CURSOR_MOVE` 里 `target = XToTime(fx − m_cursorGrabOffset)`,而**抓取读数标签时
grabOffset 很大**(标签比光标线宽),放大到一定程度后该偏移换算成的时间会落到视界之外;
`ClampCursorToView` 当时只在 `CycleCursorMode` 中调用过一次,拖拽路径根本没调用。
- 修法:`CURSOR_MOVE` 赋值后 `ClampCursorToView(which)`;`CURSOR_SPAN_MOVE` 整体夹取
(两光标间隔本身大于可视跨度时退化为各自夹取)。
- **视图变化(平移/缩放/自适应)仍然不移动光标**:贴边读数 + 方向三角是刻意行为,勿改。
- 复现/回归用例见 `[Z]`:视窗放大到 0.001×总跨度,抓标签左缘后向左右各拖 500px。
### 验证(离屏 harness `.preflight/uitest/main.cpp`,窗口 SW_HIDE、不动鼠标键盘屏幕)
- `[X]` 弱波增强算法/面板/滑块/构建:等级 0/50/100% 的目标值、健康区逐位不变、平信号不变、
滑块按压 25%/拖到 75%/拖出左端钳制 0%、缓存键(信号+等级)、SceneContentHash 失效、
构建逐位一致且独立成网格。
- `[Y]` 合成调幅纹波端到端:弱周期峰峰值 100→160(等级 50%),健康周期 200 不变。
- `[Z]` 光标拖拽始终落在 [ViewMin, ViewMax] 内。
- 合计 **295 项检查全部通过**(exit=0)。构建:`make release` + `.preflight/uitest/build.ps1`。
### 工具经验(本轮)
- `git diff --stat` 不能判断工作区行尾风格:`core.autocrlf=true` 会把 CRLF 归一化后再比对。
想查真实行尾用 `git ls-files --eol <path>`(正常应为 `i/lf w/crlf`);
DataModel.cpp 曾被改成整文件 LF,用 Python 字节级替换 `\n`→`\r\n` 复原即可。
- 拼接大段测试代码:把新代码写进 `.preflight/uitest/*.txt`,再用 Python(`newline=""` 保留 CRLF)
按“`\t// [X] ...` 注释行 + 其上一行 banner”定位并替换,比手写超长 oldString 稳得多。
## 历史存档(「差异放大」已在 67f659a 中移除,以下仅记录当时的实现,勿照搬)
### 差异放大算法(`jdi::ComputeDiffAmplify(values, window, gain, out)`,DataModel.cpp)
- 目标:纹波信号里某个周期(或半周期)的 M 形下凹明显偏弱时,把它拉回周边振幅,
且**其它数据点一点都不动**。参数:窗口点数 X(= 窗口长度)、放大倍数 N(增益上限)。
- 最终算法(全 O(n)):
1. `trend` = 半径 `2r` 的滑动平均(≈ 一个纹波周期)→ 纹波中心线;
按整周期取平均时,一个"变扁"的周期对趋势线的贡献恰好等于正常周期,**不会**把趋势线拉偏。
`r = (X-1)/2`,`X` 应**略大于纹波周期的一半**(窗口跨相位 ≥180° 才与相位无关)。
2. `strength` = 邻域内 `|value − trend|` 的**最大值**(`SlidingMax`),即局部纹波振幅。
3. `target` = `strength` 的 75 分位(直方图,O(1) 额外内存),**统计时排除首尾端点收敛区**。
4. 逐点 `gain = clamp(target/strength, 1, N)`;输出 `= trend + gain*(value − trend)`,
再夹到周边包络 `[envMin, envMax]`(半径 8r)内。
增益下界恒为 1 ⇒ **正常区域 gain 恰好 1,输出与输入逐位相同**(基准被消掉)。
5. 端点收敛:首尾各 `2r` 个点 gain≡1,再在 `2r` 个点内过渡(`taperStart/Span = trendRadius`)。
- ⚠️ 踩过的坑(都实测过):
* “|value − trend| 的**平均**”做强度:趋势线被弱区拉偏 → 周边正常点也被放大(偏离 0.5%);
* “原始采样值的**峰峰值**”做强度:峰峰值要同时含波峰与波谷才与相位无关,需要窗口跨 360°,
窗口必须 ≈ 一个周期 → 混叠区 ±周期/2 → 一个周期宽的弱区只能恢复 1/3(100→133);
* `max|交流分量|` 只需窗口跨 180° → 窗口 ≈ 半周期,弱区有足够"干净"的内部 → 100→194.9;
* 端点**不能**靠镜像延拓修正:镜像窗口覆盖的真实下标集合与截断窗口完全相同,没有新信息。
- 实测(P=128、A=100、弱区 100 的合成正弦,X=80/N=2):弱区 100 → 194.9(正常 200),
其余 17 个周期全部 200.0 → 200.0(逐周期表格已断言"只有一个周期被修改"),
信号整体值域 200 → 200,窗口远大于周期时完全不放大。
- 缓存:`m_diffAmpCurveCache`(键 = 信号指针 + X + N,`std::deque`)。
为省内存新增 `SlidingMax`(只要最大值,不分配最小值数组);`SlidingExtrema` 改为模板。
### 逐条目面板(均值滤波 / 中心轴 / 差异放大)共用
- `EdgePanelKind::DIFF_AMP`、`PlotEditTarget::DIFF_AMP_POINTS/ORDER`、`PLOT_CMD_LANE_DIFF_AMP = 11`。
- 差异放大与均值滤波共用「逐条目 + 底部按钮」排版(标题、第一排名称+复选框、第二排 X/N 输入框);
中心轴多一个第三排统计。
- ⚠️ **底部「构建为新信号」按钮必须上下各留一个 `pad`**(`height` 里加 `hasButton ? pad : 0`,
按钮位于 `top + height - pad - buttonHeight`)。原先按钮贴着面板下边框,视觉上不协调(用户指出)。
- 光标胶囊尾缀:中心轴「中心轴」、均值滤波「均值滤波」、差异放大「**差异化**」。
- 构建信号统一走 `Application::BuildDerivedSignal(fileIndex, signalIndex, points, second, diffAmp,
makeVisible)` + `ApplyPendingDerivedBuild()`(把两边的 pending 清单并成一个 `BuildRequest` 列表,
同一文件按下标降序处理),插入位置仍是源信号下方。
- 同步点:`LaneCarry`/`SplitLane`/`RemapAfterSignalInsert`/`RemapAfterSignalRemoval`、
`SceneContentHash` 都要带上 `diffAmpItems`。
### 测试经验
- 新增 `[X]`(合成正弦:算法等价性 + 逐周期表格 + 面板几何 + 光标 + 构建请求)与
`[Y]`(写一份合成纹波 CSV 真正 LoadFile 后渲染,检查派生色像素与曲线数值)。
- ⚠️ **同一个进程里第二次 `Application::Initialize` 会失败**(窗口类已注册)→
需要"空白工作区"的用例请在**共享实例**上、且放在 `TestTabAndFileCloseOnMouseUp`(唯一会清空
工作区的用例)**之后**运行。
- 验证曲线是否真的画进网格:用 `MixColor(信号色, 黑/白, 0.72)` 复算派生色,
再 `CountColor` 数该颜色的像素(与 `HighContrastDerived` 的规则一致)。
- 合计 **289 项检查全部通过**。
## 上一轮(性能瓶颈实测 + 加载字节进度 + 光标三角 + 中心轴均值滤波统一)
### 缩放后拖拽/改色卡顿 —— 实测根因是 GDI+ `DrawLine`
- 用 `.preflight/perf2/main.cpp`(离屏探针,`#define private public`)先量化再改:
缩放视界下一帧 `PaintLane` 约 50 条网格线 × 5 网格,GDI+ `DrawLine` 每条 0.1–0.19ms
→ 26.4ms/帧;包络带 `FillPath` 本身只要 0.36ms(**不是**瓶颈)。
- 修法:网格线/时间轴刻度/绘图区边框/网格分隔线全部改用 GDI 批量 1px 线
`UiDrawSolidLines(g, clip, segments, count, argb, width)`(`SaveDC`+`IntersectClipRect`+
一次 `CreatePen`+`MoveToEx/LineTo`),数值胶囊改用 `UiFillRoundRects`(一次 `FillPath`)。
- ⚠️ **GDI 绘图不受 GDI+ `SetClip` 约束** → 所有 GDI 批绘必须显式给 clip。
半透明网格线颜色必须先用 `UiBlendOver(cardBg, WithAlpha(color, alpha))` 预混为不透明色,
否则 GDI 无法画 alpha。
- 实测:缩放视界 `场景缓存失效帧` 26.4 → 8.25ms,`ApplyColor+Render` 7.1 → 3.15ms,
缓存命中帧 2.06 → 0.30ms。
### 加载进度显示字节数
- `CsvProgress` 由 `std::function<bool(double)>` 改为 `std::function<bool(const CsvProgressInfo&)>`,
`CsvProgressInfo{ratio, bytesRead, totalBytes, hasBytes}`;列重组阶段 `hasBytes=false`(只显示百分比)。
- `Utf8Util::FormatByteSize(unsigned long long)` → 自适应 GB/MB/KB(1024 进制,`%.2f`)。
- ⚠️⚠️ **MinGW 的 `swprintf` 按 C99 语义:宽格式串里的 `%s` 表示窄字符串**,
写 `%s` 传 `wchar_t*` 会把 "KB" 截成 "K"。宽字符串必须用 `%ls`。
- 浮层文本:`FormatByteSize(read) + " / " + FormatByteSize(total) + " " + "%.2f%%"`。
### 光标读数(常规光标)
- 双光标重叠时按**时间值**排序:小值在上、大值在下(原来固定把光标 2 上移,左右交换后是错的)。
- 新增 `CursorOffViewSide(which, &offRight)` 判定读数是否被钉在视界边缘;
`PaintCursorOffViewTip()` 在胶囊上方画方向三角。视界之外时叠放间距改为
`kCursorTipStackGap = arm*1.4 + 4`,让三角正好落在两行之间的空隙(否则压在相邻胶囊上)。
- 双光标同时越界到同一侧:差值读数与两条时间读数**同列**垂直排列(`CursorSpanRect` 新增分支),
并且差值读数也画方向三角。
### 中心轴边沿计数与均值滤波统一
- `Lane::edgeCenterSmooth` → `std::vector<Lane::EdgeCenterItem> edgeCenterItems`
(`using EdgeCenterItem = MeanFilterItem`,字段同为 `points(X)` / `order(N)`),
新增 `Find/Ensure/RemoveEdgeCenterItem` / `EdgeCenterEnabled` / `ToggleLaneEdgeCenterSignal`。
- 曲线统一走 `MeanFilterCurveFor(signal, X, N)`(删掉 `CenterCurveFor`/`CenterValueAt`/
`m_centerCurveCache`/`PaintCenterCurve`),`EdgeCenterCountsFor(file, signal, X, N, ...)`。
- 面板:CENTER 与 MEAN 共用「逐条目」排布(`itemized`),每条目三排
(名称+复选框 / X·N 输入框 / 中心轴的统计结果),未勾选只画第一排;
`EdgePanelLayout` 新增 `rowStats`。`PlotEditTarget::EDGE_CENTER_SMOOTH` 拆成
`EDGE_CENTER_POINTS` / `EDGE_CENTER_ORDER`;中心轴面板不再有底部编辑行。
- 同步点:`LaneCarry`/`SplitLane`/`RemapAfterSignalInsert`/`RemapAfterSignalRemoval`
与 `SceneContentHash` 都要带上 `edgeCenterItems`。
## 构建
- 根目录执行 `make release` / `make debug` / `make all` / `make clean`(MinGW-w64 15.2.0 UCRT,make 4.4.1)。
- 产物:`Build/Release/Target.exe`、`Build/Debug/Target.exe`(GUI 子系统,无控制台)。
- Makefile 已加入 `-lgdiplus -lcomdlg32`;入口点是 `WinMain`(Makefile 未使用 `-municode`)。
## 结构约定(必须遵守)
- `Components/<组件名>/Inc|Src`,`Utilities/Inc|Src`;文件名只能用字母+下划线。
- 新增目录必须同时补进 Makefile 的 `CXX_INCLUDES_PATHS` / `CXX_SOURCES_PATHS`。
- 目标文件按 **basename** 去重且用 vpath 查找 → 所有源文件名必须全局唯一。
- 头文件保护宏格式:`__文件名全大写_HPP__`(双边双下划线)。
- Makefile 用 `-finput-charset=UTF-8 -fexec-charset=GBK -fwide-exec-charset=UTF-16LE`:源码 UTF-8,宽字符串字面量可直接写中文。
## 代码要点
- 命名空间 `jdi`;`namespace gd = Gdiplus;`(定义在 Theme.hpp)。
- UI 为**单窗口手绘 + 区域命中测试**(Toolbar/SignalPanel/PlotView 各管一块),非子控件。
- 文本用 GDI `DrawTextW`(经 `Graphics::GetHDC()/ReleaseHDC()` 与 GDI+ 混用),保证清晰且定位精确;
切勿把自定义函数命名为 `DrawText/DrawTextEx/GetObject` 等与 winuser 宏冲突的名字(本项目用 `UiText/UiFillRoundRect` 前缀)。
- 双缓冲:`Application::EnsureBackBuffer`,WM_SIZE/WM_DPICHANGED 时释放重建。
- 网格数量变化会在 `Render()` 中触发 `Relayout()`(影响滚动条与绘图区宽度)。
## 架构(数据与视图配置分离)
- `Utilities/Inc/DataModel.hpp`:`DataSet`(多文件只读数据层,`DataFile`/`SignalData`)+ `TabConfig`(每标签页独立配置)。
- `SignalRef{fileIndex,signalIndex}` 是全局信号标识;**不要**再引入单文件假设。
- **显示外观属于信号本身、可见性属于标签页**(关键分工,勿混):
- `SignalStyle{color,customColor,opacity,lineWidth,mode}` 存在 `SignalData::style`(DataSet 内),
是**全局共享**的 → 任一视图里改颜色/线宽/透明度/样式,其它视图自动一致。
访问一律走 **`DataSet::Style(ref)`**(`const`/非 const 两个重载)。
- `TabConfig` 只存 `m_visible`(`vector<vector<bool>>`)与 `Lane`/折叠/选中/视图状态。
可见性接口仍为 `TabConfig::IsVisible/SetVisible/ToggleVisible/SetFileVisible/SetAllVisible/SoloSignal`。
- **不要再给 `SignalStyle` 加 `visible` 字段**,也**不要**恢复 `TabConfig::Style()`(已删除)。
- 组件侧辅助:`SignalPanel::StyleOf(ref)`(内部取 `m_dataSet(_Writable)->Style`);
`PlotView::ResolvedSignal{data,style,file,visible}`,`visible` 来自 `m_tab->IsVisible(ref)`。
- 新增信号的默认外观在 `DataSet::AddFromTable` 里按**全局信号序号**取调色板色(`PaletteColor` 已删除,改内联)。
- 可见性改动必须走上述 `Set*` 接口——它们内部会 `SyncLanes()`,
按 `LaneMode`(PER_SIGNAL/MERGED/CUSTOM)重建网格,**这正是"取消勾选后空网格消失"的保证**。
- `RebuildLanes(可见性表, lanes, mode)` 的入参已从 `styles` 改为 `m_visible`。
- **信号"选中"属于 `DataSet`(跨视图共享),不属于 `TabConfig`**:
`DataSet::Selected()/SetSelected(ref)`、`ValidateSelection()`、`RemapSelectionAfterFileRemoval()`。
`TabConfig::m_selected`、`TabConfig::Style()`、`SignalStyle::visible` **均已删除**,不要恢复。
组件侧入口:`PlotView::SelectedRef()/SelectSignal()`、`SignalPanel::SelectedRef()/SelectSignal()`;
`Application::SetActiveTab` 会把左面板滚动到 `m_dataSet.Selected()`。
→ "视图 1 里选中信号,切到视图 2 仍是选中"就靠这条(不要在 TabConfig 里重建选中态)。
- 文件增删顺序:各标签页 `RemapAfterFileRemoval(idx)` → `DataSet::RemoveFile(idx)` → 各标签页 `SyncToDataSet()`。
- `Application::SynchronizeView(force)` 在 `Render()` 开头统一收敛:检测可见数/网格数变化 →
**无可见信号时 `tab.ViewInitialized()=false`**,有可见信号且未初始化时 `FitAll()` → `Relayout()`。
这条复位是"清空网格/取消全部勾选后重新勾选第一个信号会重新自适应"的保证。
- 组件按 `(const DataSet*, TabConfig*)` 绑定;PlotView 内部保留 `m_localTab` 占位对象以避免空指针分支。
## 弹出菜单(UI 统一风格的关键)
- 本项目**禁止**使用 `CreatePopupMenu/TrackPopupMenu/AppendMenuW`(系统菜单风格与主题不符)。
- 统一用 `Utilities/Inc/PopupMenu.hpp` 的 `PopupMenu`:不建窗口,作为最后一层直接画进后备缓冲区。
- 用法:宿主调用 `m_menu.Open(MenuScope, bounds, x, y, items)`(x/y 为**客户区**坐标)→ 之后每帧 `m_menu.Paint(g)`;
`HandleMessage` 开头在 `m_menu.IsOpen()` 时拦截并路由鼠标/键盘(Esc/↑/↓/Enter),失焦或菜单外释放自动关闭。
- 菜单项支持 `NORMAL/SEPARATOR/HEADER/SWATCHES/EDIT`;选择结果经 `MenuResult{scope,id,swatchIndex}` 派发回来源组件
(信号面板命令 ID 见 `SignalPanel.hpp` 的 `MenuCommand`)。
- **文本列统一为 `kPadH + kItemPad`**(`kPadH=5`、`kItemPad=8`,匿名命名空间常量):
HEADER / NORMAL / EDIT 三类在 `BuildLayout`(宽度)与 `Paint`(x)里都用它。
**`kRightPad` 已删除**;不要再把选中标记列放在左侧——那会把整份菜单的文本左边距整体推右。
- **勾选对勾(`item.checked`)画在条目"右侧"的标记列**(`kMarkerColumn=18`,右侧再留 `kItemPad`):
`markerRight = rect.X + rect.Width - kItemPad*scale`,对勾中心 `markerRight - kMarkerColumn*scale*0.5`。
普通项文本宽度必须预留 `kMarkerColumn + kMarkerGap(4)`,否则打勾时文字会与对勾重叠。
- EDIT 行的输入框**靠右对齐到条目内容区右边界**(`rect.X + rect.Width - kItemPad*scale - width`),
宽度由 `NaturalEditFieldWidth(item)` 计算(排版与绘制共用同一公式),并在空间不足时给左侧
说明文字保留自然宽度(避免菜单被 `kMaxWidth` 夹取后标签被省略成 "…")。
分隔线/色块行的左右内缩同样用 `kItemPad`(色块起点 = `kPadH + kItemPad`)。
- **易漏点**:`Open()` 之后必须 `InvalidateRect`,否则菜单不会显示(曾在标签栏右键菜单上踩过)。
## 样式编辑区(SignalPanel 下半部分)
- **固定四行**:颜色 / 样式 / 透明度 / 线宽(**线宽在最下**);行高 26、行距 10、上下内边距 12(逻辑像素),
高度由 `DesiredStyleHeight()` 恒定 → **面板缩放时布局不会散架**(勿再引入会改高度的内联展开区)。
- **统一内容列 + 拉伸上下限**(`LayoutStyleControls`):四行共用同一 `m_styleContentRect`,宽度 = `min(可用宽度, 260)`
(260 为逻辑像素上限,**拉宽面板时控件不再延伸**);标签列 + 内容列整体**靠左对齐**
(`groupLeft = m_styleRect.X + padH`,**不要**再取"居中",拉宽面板时控件不得向中间偏移)。
行标签一律用 `m_styleRowRects[i]` 的整行矩形绘制以获得垂直居中,
**不要**用某个控件的 Y 作标签 Y(会造成"颜色"字样与色块的高低差)。
- 颜色行的**色块间距自适应**:`gap = (contentWidth - size*(count+1)) / count` 并钳到 [4,10],
使该行与其它三行等宽;**颜色行同样靠左**(`colorRowLeft = contentLeft`),不再整行居中。
- 实测(面板 507px 宽时):颜色行与样式行均占 89–413px,右侧留 93px 空白 → 左对齐 + 上限同时成立。
- 极窄时数值文本自动省略 `px` 单位(`UiTextWidth(full) > rect.Width - 6*scale` 时只画数值)。
- **无标题行**,选中信号即编辑目标。
- 颜色 = N 个预设色块 + **同尺寸**「加号」方格(尺寸夹在 11–18 逻辑像素,宽度不够时递减预设数量)。
加号方格弹 `ColorPickerPopup`(浮动 RGB 面板,与 PopupMenu 同为最上层叠加绘制)。
方格**始终填充记忆的自定义色**(`SignalStyle::customColor`),自动选浅色/深色加号图标(`IsDarkColor`,亮度阈值 140)。
- **自定义色与预设色的"谁高亮"规则(勿改回)**:
`customActive = (style->color == style->customColor) && 当前颜色不属于任何可见预设色`。
**必须加上后半条**:否则自定义色恰好等于某个预设色时,点那个预设色块会把高亮挪到自定义色块上
(用户报告的"点预设色块 1 直接跳到自定义色块")。两个色块同时代表同一颜色时**预设色优先**。
- **`customColor` 初值 = 调色板中"本信号预设色向后递进一格"的颜色**
(`palette[(slot + 1) % count]`,`slot = 全局信号序号 % 调色板数`)。
原先是 `customColor = color`(与自身预设色相同)→ 那正是上述缺陷的根源。
因此同一文件里信号 N 的自定义色就是信号 N+1 的预设色,这是刻意的"向后递进"排布。
- **透明度与线宽都是滑块**(几何完全一致以保证纵向宽度对齐):
- 透明度 = `SignalStyle::opacity`(0-1,默认 1)。
- 线宽 = `style->lineWidth`,滑块范围 **0.5–10 px**。
- **滑块统一走 `PaintUtil::UiDrawSlider(g, track, ratio, fillColor, active, scale)`**
(样式区的透明度/线宽、拾色器的 RGB 三处都用它;勿再各自实现)。
**关键**:填充宽度必须严格 `= ratio * track.Width`,**不得**设 `max(track.Height, ...)` 的最小值——
否则滑块在最小值时会在圆钮右侧留下一条填充残留(圆钮半径 3.8 只覆盖 ±3.8px,而最小填充宽 6px)。
### 内联文本输入(统一模型:TextEdit)
- **所有输入框共用一个编辑模型 `Utilities/Inc/TextEdit.hpp`(`TextEdit`)**,字段:
`m_editField/m_editRow` + `m_edit`(SignalPanel)、`m_editField` + `m_edit`(ColorPickerPopup)、
`m_editTarget` + `m_edit`(PlotView)、`m_editIndex` + `m_edit`(TabBar)。
**`m_editText`/`m_editFresh` 已全部删除**——不要再各自维护字符串缓冲。
- 能力:光标/选区(`m_caret`+`m_anchor`)、`EditFilter{ANY,NUMBER,HEX,BYTE}`、限长、
Ctrl+A/C/X/V(粘贴按过滤器清洗)、Shift+方向键与 Home/End 选区、退格/删除清除选区、
代理对安全步进。`Begin(text, selectAll=true, filter, maxLen)` 即"整段选中"。
- **渲染统一 `PaintUtil::UiDrawEditField(g, rect, const TextEdit&, font, scale)`**
(签名已从 `(text, fresh)` 改为 `const TextEdit&`):选区高亮 + 插入符,
**文本超出框宽时水平滚动并保证插入符可见**;整段选中时文字用 `textOnAccent`,
局部选区只把选中片段重绘为 `textOnAccent`。
- 键盘/字符路由仍在各组件 `OnEditKey/OnEditChar`:`WM_CHAR` 的 `3/0x18/0x16/0x01` = Ctrl+C/X/V/A;
Shift 状态用 `GetKeyState(VK_SHIFT)`(在 `WM_KEYDOWN` 里读)。
**退格/删除只允许在 `OnEditKey`(WM_KEYDOWN)里处理**:WM_CHAR 也会送来 `\b`,
两边都处理会导致"一次退格删除两个字符"。`OnEditChar` 里一律 `character < 0x20 || == 0x7F` 直接 return false。
- **选区收拢语义(与常规输入框对齐,均已在 TextEdit 内实现)**:
- `MoveCaret(±1, extendSelection=false)` 且有选区 → 跳到选区左/右端并清除选区。
- `MoveHome/MoveEnd(extendSelection=false)` → 无论插入符是否已在端点,都要收拢选区
(不能沿用 "已在端点就 return false" 的短路,否则整段选中后按 End 不会取消全选)。
- `DeselectToEnd()` → 取消选区并把插入符移到末尾;由各组件 `CollapseEditSelectionToEnd()` 暴露,
Application 在 `WM_RBUTTONDOWN` 最前面调用(编辑态下右键不再弹菜单/拖动)。
- **鼠标编辑已补齐**(一律走 TextEdit,勿再各自实现):
`PlaceCaret(offset)` / `BeginDrag(offset)` / `UpdateDrag(offset)` / `EndDrag()` / `Dragging()`;
组件侧统一入口 `OnEditMouseDown/Move/Up`。共享几何与命中测试在 `PaintUtil`:
`UiEditTextLeft(rect, edit, font, scale, float& innerWidth)`、`UiEditOffsetAt(font, text, localX)`、
`UiEditFieldMouseDown/MouseMove`。**`m_dragging` 必须在 `End()` 里清零**,
否则下次进入编辑会带着上一轮的拖拽态。
- **输入框不得再设长度上限**:一律传 `maxLen = 0u`(0 = 无限)。
`SignalPanel`/`TabBar`/`ColorPickerPopup`/`PlotView` 均已改为 `0u`。
- 文本宽度测量用 `UiMeasureTextWidth`(HDC 无关),避免输入时横向抖动。
- 剪贴板:`Utf8Util::CopyTextToClipboard` / **`PasteTextFromClipboard`**(后者把换行/制表符折叠为空格)。
宿主句柄:`TabBar::SetHostWindow` / `PlotView::SetHostWindow` / `ColorPickerPopup::SetOwnerWindow`。
### 内联文本输入的字段配置(沿用)
- 样式区用 `StyleEditField{NONE,WIDTH,OPACITY,SIGNAL_NAME,FILE_NAME}`,入口 **`SignalPanel::IsEditing()`**。
- 拾色器用 `ColorPickerPopup::EditField{NONE,R,G,B,HEX}`,入口 `IsEditing()`;
RGB 用 `BYTE` 过滤 + 限长 3,色号用 `HEX` + 限长 7(接受带或不带 `#`)。
- 样式区的线宽/透明度用 `NUMBER`(透明度提交时 /100 并钳到 0-1),名称类用 `ANY` + 限长 64/96。
- 提交:回车 / 点击框外 / 失焦;Esc 取消。**`ColorPickerPopup::Close()` 会先 `EndEdit(true)`**,
避免用户输入被静默丢弃。
- **颜色样块统一走 `PaintUtil::UiDrawSwatch(rect, color, opacity, radius, scale)`**:
按不透明度填充,且**仅当 `opacity < 0.45` 时**补一圈描边(`UiSwatchNeedsOutline`)。
预设色块、自定义方格、信号行色块、波形图例色块全部走它——**可见时不画描边**,
否则会出现"透明度 0% 完全不可见"或"满不透明度仍有边框"两类缺陷。阈值常量在 PaintUtil.cpp 的匿名命名空间。
- `DrawMode` 只有 LINE/SCATTER/LINE_SCATTER(STEP 已删除)。
- 网格归属不在样式区,由每条信号行末尾的 G 徽标承载(点击弹菜单)。
## 绘图区(PlotView)
- **新加载信号的默认线宽是 `1.0f`**(`SignalStyle::lineWidth`,在 `DataModel.hpp` 声明与
`DataModel.cpp::BuildDataFile` 的样式初始化两处,改的时候要一起改)。
- 工具栏按钮文案:**「时间轴选区」「Y 轴选区」**(原「时间轴缩放 / Y 轴缩放」已改名;
`ToolbarAction::ZOOM_TIME/ZOOM_VALUE` 枚举名未改)。状态提示语同步为「…选区」。
- **散点半径随线宽线性增长**:`radius = clamp(lineWidth * 1.5, 1.6, 12.0) * scale`
(常量 `kScatterRadiusScale/kMinScatterRadius/kMaxScatterRadius`)。
**不要**再乘一个很小的系数并把上限设成 3–4px——那会让线宽超过 ~2px 后数据点不再变大。
### 大文件性能(百万级采样)
- 实测规模:`TestData/BIG_DATA.csv` = **128 万行 × 11 列**;
压测用 `TestData/VERY_BIG_DATA.CSV`(用户提供)= **1.77 GB、约 2580 万行 × 6 列(5 信号)**
(按 72 字节/行采样估算;真实行数以程序内为准)。
- **顶点数就是绘制耗时**(GDI+ 抗锯齿 `DrawLines` 实测约 2.2µs/段,round join/cap 最贵)。
优化前固定 `kMaxPolylinePoints = 20000`/条。
- 五项优化(都在现有单线程架构内):
1. **像素列上下包络抽稀**(`BuildPolyline` 的包络分支):预算由绘图区宽度决定
(`plotWidth * kPolylinePointsPerPixel(2)`,下限 `kMinPolylinePoints(512)`,上限仍 20000)。
**列边界必须用 `searchFrom` 单调推进**(不要每列在全区间 `lower_bound`,缓存缺失很贵)。
→ MUST NOT 再用"限制每列采样点数"来降级(见第 4 条)。
2. **粗粒度包络层**(`SignalData::coarseMin/coarseMax/coarseStride`,`kCoarseStride = 256`):
在 `ComputeStats` 里与 min/max 统计**同一个循环**内构建(不额外扫数据),
额外内存 ≈ 采样数据的 0.8%。包络读取"每列覆盖到的分组"后,
读取量从 O(可见采样数) 降到 O(像素列数 × 少量分组)。
条件:`(i1 - i0) >= 2 * coarseStride` 时用分组层,否则直接扫原始采样(此时可见采样数很少)。
→ VERY_BIG 全选时视图变化的包络构建 **196ms → ≈0ms**,且**绘制结果与全量扫描一致**。
**语义差异**:分组层只在"整组无有效值"时断线,密集视图中孤立 NaN 不再把波形切成两段
(原先会)——视觉上更干净,刻意保留这个行为。
3. **折线顶点缓存** `m_polylineCache`(`std::deque<PolylineCacheEntry>`,上限 64 条,
`AcquirePolyline`):键 = 构建折线实际读取的**全部**输入
(ref、file 指针、采样点数、ViewMin/ViewMax、plotRect.X/Width、lane.rect.Y/Height、lane.yMin/yMax)。
**deque** 保证 push_back 后已有元素引用有效(vector 会失效)。
4. **不要改几何/顶点数来降级**(已踩坑并回滚):曾经在 `m_dragMode != NONE` 时"顶点预算减半 +
每列只扫 32 点",用户报告"拖拽时波形变模糊"(包络丢峰值)。
**允许的降级只有"关抗锯齿"**(几何逐点不变),而且必须保证随后补一帧全质量:
用户明确同意"先用低分辨率,绘制完成后用高分辨率替换"。
实现:`Paint()` 开头 `m_fastFrame = (m_dragMode != NONE)`;
快速帧之后**仍然**捕获场景缓存但打上 `m_sceneDegraded` 标记 → `SceneCacheMatches()` 拒绝命中 →
松手后的下一帧必定重画全质量(并且只在快速帧关 AA)。
⚠️ 关 AA 一定要覆盖**包络带填充之外**的折线分支,否则中等缩放(约 2 采样点/像素)
下波形走 `DrawLines` + AA,交互帧依旧 50ms。
另:包络带在"梳状"(相邻列上下包络互不相交的列数 > 1/8)时也关 AA ——
同一路径实测 AA 50.7ms / 无 AA 4.2ms,而观感几乎无差别。
5. **散点必须合并成一条 `GraphicsPath` 再 `FillPath`**,不要逐点 `FillEllipse`:
实测 2500 点 63ms → 7ms(基准),程序内密集信号 51ms → 22ms,仍是抗锯齿圆形。
⚠️ **必须紧跟 `SetFillMode(gd::FillModeWinding)`**:`GraphicsPath` 默认是
`FillModeAlternate`(奇偶填充),重叠圆形的交叠区会被反向填充,
视觉上就是用户报告的"数据点重叠处出现白色缝隙/白边"。
主绘制与拖拽 ghost 的 path 都要设(`.preflight/fill_test.cpp` 可复现对比)。
6. **静态场景缓存(当前架构,务必按此维护)**:解决"大文件下任何悬停/选区拖拽都重绘波形"的卡顿。
- 缓存内容 = **仅绘图区 `m_area`**(底色 + 全部网格 + 时间轴)。工具栏/标签栏/面板
**每帧重绘**(它们各自带悬停态,缓存会冻结悬停效果);`Application::Render` 已不再有
`reuseScene` 分支(`PlotView::CanReuseSceneCache` 已删除)。
- **任何一次 `Paint` 只要键一致就贴图**(不再限定拖拽模式):`SceneCacheMatches()` + `BlitSceneCache()`。
- 键 `SceneCacheKey` = viewMin/viewMax/laneScroll/laneCount/area*/scale
+ `haloFile/haloSignal`(`SceneHaloRef()`:仅拖拽中的信号光晕会进入缓存)
+ `contentHash`(`SceneContentHash()`,FNV-1a 折叠 网格几何/幅值 + 每条信号的
ref/可见性/color/opacity/lineWidth/mode/`Title()` 与所属文件 `Title()`)。
→ 改颜色/线宽/透明度/样式/重命名/增删信号都会自动让缓存失效,**不需要**到处手工调失效接口。
仍保留 `InvalidatePolylineCache()`(数据层结构变化)→ 它内部调用 `InvalidateSceneCache()`;
`SetContext/SetScale/Layout` 也各调一次。
- **随鼠标变化的内容绝不能进缓存**,一律在贴图之后绘制:
`PaintHoverHighlight`(悬停网格底色 + 悬停信号光晕)、`PaintLaneReadouts`(光标读数)、
`PaintRubberBand`、`PaintCursor`、`PaintSignalInteraction`(拖拽虚影 + 提示气泡)、
`PaintEditField`、`PaintScrollBar`、外框。
悬停网格底色改用半透明叠加(`WithAlpha(accent, 0x0A)`)才能画在波形之上;
悬停光晕由 `PaintSignalHalo()` 在贴图后绘制(绘制在曲线**之上**,观感即"高亮")。
- `PaintLane` 内只对 `SceneHaloRef()`(拖拽中的信号)画光晕。
- ⚠️ **`EnsureSceneCache` 要求位图尺寸与当前区域"严格相等"**(原实现是 `>=` 复用):
贴图按位图尺寸整块拷贝,沿用更大的旧位图会在窗口缩小后把旧像素画到绘图区之外。
- 实测(VERY_BIG 5 信号):整幅重绘 ≈80ms,贴图 ≈1ms,工具栏+标签栏+面板 ≈10.5ms。
悬停帧 95~105ms → **面板悬停 ≈14ms**、**网格内悬停(含光晕)≈28~32ms**;
时间轴/Y 轴选区拖拽 95~105ms → **19~22ms**;`MOVE_SIGNAL` 拖拽 ≈28ms;空闲 CPU 0.00%。
- 缩放/改面板宽会走 `Layout()` → 失效 → 重捕获;`.preflight/resize.ps1` 可自动缩放窗口验证。
- **网格右键菜单里的「选区 Y 轴自适应」是切换开关,不是一次性操作**:
- 状态存储在 `Lane::fitYToSelection`(默认 false)。开启后**每次时间轴选区放大**
(`PlotView::ApplyTimeSelection` → `ApplySelectionAutoFit()`)都会把该网格的 Y 轴
匹配到新的可见时间区间(复用 `AutoRangeLaneToView`)。
- **打开开关时立即自适应一次**(`ToggleLaneAutoFitToSelection` / `SetAllLanesAutoFitToSelection(enabled, applyNow=true)`);
`Application` 的菜单项与工具栏「Y 轴自适应」都传 `applyNow=true`。少了这一步用户会觉得
"点了没反应"(这正是用户报告的问题)。
- **「按网格时间轴展开」(`FitTimeToLane`)在展开时间轴后也会对该网格做一次 Y 轴自适应**。
- 手动改幅值会**关闭该网格的开关**:`ApplyValueSelection`(Y 轴选区放大,用户明确要求)、
`ScaleLaneRange`、`ShiftLaneRange`(纵向平移)、`ApplyLaneValueText`、`EndEdit`。
`FitAll` **不**清除开关(那是用户设置项,不是一次性结果)。
- `RebuildLanes` 会**按信号继承**开关(快照旧网格的信号→开关,重建后回填),
否则勾选/取消勾选信号会把用户刚打开的开关悄悄清掉。
- 查询/操作接口:`LaneAutoFitToSelection` / `AllLanesAutoFitToSelection` /
`ToggleLaneAutoFitToSelection` / `SetAllLanesAutoFitToSelection` /
`ApplyAutoFitToSelectionNow` / `ApplyAllAutoFitToSelectionNow`。
- 全局项与工具栏按钮的勾选 = `AllLanesAutoFitToSelection()`(有网格且全部开启);
任意网格单独关闭后全局立即变为未勾选。
- `RefreshPlotMenuValues` 里同步刷新这两项的勾选状态。
- 实测汇总(VERY_BIG 全选 5 信号,全视图):
包络构建 ≈0ms(缓存命中);线段绘制 54ms;网格/标尺 10ms;其余约 15ms;
平移拖拽整帧 90~120ms(优化前约 336ms);空闲 CPU 0.00%。
加入静态场景缓存后:**拖拽帧 134~146ms → 22~25ms**(散点/点线拖拽约 24.6ms),
非拖拽重绘维持 ~118ms(缓存只在拖拽中生效,故静止帧不变)。
- **结论:不要为波形构建引入多线程**(粗粒度层已把构建降到 ≈0ms,剩余是 GDI+ 单线程绘制)。
**加载路径的多线程收益明确**,已在那里引入(见下)。
- **数据层结构变化后必须 `PlotView::InvalidatePolylineCache()`**(已在
`Application::SyncTabsToDataSet()` 与 `SetContext()` 调用)。
### 裁剪(重要陷阱)
- **GDI 文本不遵循 GDI+ 裁剪区**:`UiText` 走 `GetHDC()+DrawTextW`,`Graphics::SetClip` 对它无效;
GDI+ 矢量绘制(折线/散点/矩形边框)则正常受裁剪约束。
→ 纵向滚动网格时,文字会越过 `m_plotRect` 顶部画到工具栏上(波形不会)。
**必须**对文本显式判定:`PlotView::IsFullyVisible(rect, visibleTop, visibleBottom)`;
已在幅值刻度标签、`PaintLaneLegend`、`PaintLaneReadout`、空网格提示处接入。
新增网格内文本时务必同样处理,不要只依赖 `g.SetClip`。
- **确实需要让 GDI 文本被裁剪时**:用 `PaintUtil::UiTextAtClipped(g, text, font, x, rect, argb, clipRect)`
——内部是 `ExtTextOutW` + **`ETO_CLIPPED`**(注意常量名:`ETO_CLIPPING` **不存在**,写错直接编译失败)。
**但不要用它画标尺刻度数值**:GDI+ 会先合成半透明衬底,数字看起来发灰/像被截断;
标尺标签一律继续用 `UiText` + `IsFullyVisible` 严格可见性守卫。
- **图例与标尺重叠只靠绘制顺序解决**:标尺数值先画,图例最后画(覆盖其上)。
**不要**用"检测到重叠就跳过绘制"的方式避让——那会让刻度整段消失(原缺陷)。
`LegendBandRect` 已删除,不要再引入。
- 图例文本宽度上限 `kLegendMaxTextWidth = 200.0f`:超过才省略号截断。
上限调太小会误伤正常名称(曾设 120 把 `[0]Resample Current (mA)` 截成 `[0]Resample Current `)。
- 网格内容裁剪用 `clipTop=max(lane.rect.Y, m_plotRect.Y)` / `clipBottom=min(lane 底, 绘图区底)`,
两者相等直接 return(网格完全滚出视野)。
- 时间轴刻度标签横向夹在 `[m_plotRect.X, 右边界]` 内,否则首个刻度会压到左侧时间列名(曾出现 `time0` 重叠)。
### 布局与交互模型(当前约定)
- 网格之间**没有间隔**(`gap = 0`,`laneHeight = m_plotRect.Height / count`),
边界靠每个网格顶部的**较重横线**表示(首个网格 1.6px `axisLine`,其余 2px `borderStrong`,
常量 `kLaneDividerWidth`);绘图区整体外框用 `axisLine` 1.6px。
- `m_timeAxisHeight = 34`、`m_minLaneHeight = 54`、左右留白 `kPlotSidePadding = 8`。
绘图区高度先扣掉时间轴再分给网格(`availHeight = area.Height - timeAxisHeight - topPadding`),保证时间轴不被挤压。
- **纵轴标尺在网格内侧**:不再为左侧标签列预留宽度(`m_yAxisWidth` 已删除),
刻度数值画在网格**左内侧**,且必须在**波形之后**作为覆盖层绘制,每个数值带一个
半透明圆角底(`WithAlpha(cardBg, 0xF0)`,内缩 `kLaneLabelPad`)。
**不要**用一整条不透明衬底盖住左侧——那会把波形左段整块糊掉;也**不要**在波形之前画,
否则曲线会压在数字上(GDI+ 会正确合成 alpha,但需要放在波形之后)。
图例起点相应右移 `m_labelBandWidth`(仍保留该成员作为图例缩进)。
- 时间轴列名(首个可见文件的 `timeColumnName`)**贴左**绘制,并跳过与之重叠的刻度标签。
- **滚轮不承担缩放**:默认纵向滚动网格、Shift+滚轮水平平移。
- **缩放靠模式按钮**:`TabConfig::ZoomMode()` = NONE/TIME/VALUE,由工具栏「时间轴缩放」「Y 轴缩放」切换
(`ToggleZoomMode` 再次点击同一模式即取消)。拖拽产生 `RUBBER_TIME`(贯穿全高的竖向选区)或
`RUBBER_VALUE`(限定在按下时网格内的横向选区),释放时经 `ApplyTimeSelection/ApplyValueSelection` 生效。
`FitAll()` 会把 ZoomMode 复位为 NONE("适应视图 = 回到初始状态")。
**没有**右键框选缩放;绘图区右键改为弹出**网格 Y 轴菜单**(见下)。
- **常规模式(ZoomMode::NONE)下拖拽 = `PAN`**:水平位移平移时间轴、纵向位移
**只偏移光标所在网格**的幅值显示位置(`m_dragLane` 在按下时记录)。
纵向偏移经 `ShiftLaneRange`(首次会先把自适应范围固化再偏移)。
- **信号拖拽换网格**:`MOVE_SIGNAL`。`HitTestSignal` 用**折线到光标的点到线段距离**
(容差 `kSignalHitTolerance = 6` 逻辑像素;散点模式按采样点距离)只在鼠标所在网格内检测。
悬停命中 → `m_hoverSignal` → 曲线画 accent 光晕 + 提示气泡(`PaintSignalInteraction`,用 `m_mouseX/m_mouseY` 定位)。
**只有命中提示时按左键**才进入拖拽。
- **拖拽反馈**:虚影以**源网格**的幅值映射构建波形(形状与你抓住的一致),
再按 `m_dragMouseY - m_dragStartY` **整体纵向平移 → 跟随鼠标移动**(`OnMouseMove` 里记录 `m_dragMouseY`);
裁剪用**整个绘图区**(不是某个网格)。同时高亮鼠标所在网格边框并显示提示气泡
(同网格为「松开左键放回本网格」,否则「移动到网格 N:松开左键确认」)。
松开时若目标网格 ≠ 源网格则 `MoveSignalToLane`。
**不要**再用目标网格的 yMin/yMax 去构建虚影——那会让形状突变且看起来"没有跟随鼠标"。
- **网格归属**由 `MoveSignalToLane(dataSet, ref, laneIndex)` 维护。
**关键**:目标网格必须先按"锚信号"(该网格中除被拖信号外的第一条)固定下来,
再移除源信号并剔除空网格,最后用锚信号**按引用**找回目标网格追加。
若直接沿用传入的 `laneIndex`,空网格回收会让下标错位 —— 表现为"与原网格交换位置"而非合并。
`laneIndex == 当前所属网格` 时直接返回(避免网格回收造成顺序抖动)。
- **未分配网格的可见信号各自独占一个新网格**(CUSTOM 分支末尾)。
**不要**再把它们塞进 `lanes[0]`——那会让"拖拽合并后再勾选新信号"被并入合并网格。
`SetVisible(ref, true)` 在 MERGED 模式下只需切到 CUSTOM,随后由 `RebuildLanes` 自动分配。
## 鼠标与验证补充
- **网格右键菜单**(`MenuScope::PLOT_VIEW`,命令 `PLOT_CMD_*`)**当前顺序(用户明确要求)**:
1. HEADER `网格 N`
2. `EDIT` `Y 轴最大值` + 值(`PLOT_CMD_LANE_Y_MAX`)
3. `EDIT` `Y 轴最小值` + 值(`PLOT_CMD_LANE_Y_MIN`)
4. `NORMAL` `选区 Y 轴自适应`(`PLOT_CMD_LANE_AUTO_VIEW`,**切换项**,勾选 = 该网格开关开)
5. `NORMAL` `按网格时间轴展开`(`PLOT_CMD_LANE_FIT_TIME`)
6. SEPARATOR
7. `NORMAL` `选区 Y 轴自适应(全局)`(`PLOT_CMD_AUTO_FIT_ALL`,**切换项**)
8. `NORMAL` `全部自适应`(`PLOT_CMD_FIT_ALL`)
**`恢复 Y 轴自适应` / `PLOT_CMD_LANE_AUTO` / `LaneRangeIsAuto` / `ResetLaneRange` 已删除**
(不要再加回来;要恢复整段自适应请用「全部自适应」/适应视图/绘图区双击)。
menu 实测坐标(125%、窗口 1725x996、rclick 于客户区 (900,300) 时菜单向左下展开):
菜单左边界截图 x≈908、右边界≈1145 → 点击用客户区 x≈991;
各条目中心客户区 y ≈ 354 / 393 / 428 / 463 / 506(全局) / 540(全部自适应)。
⚠️ 菜单纵向位置随锚点/内容变化,**每次测试前先截图量一次**,不要沿用旧值。
- **上下限就是菜单里的内联输入框(`MenuItemKind::EDIT`)**,不再跳到网格左上角编辑:
单击进入菜单内编辑、回车/点别处提交,**菜单保持打开**(与拾色器 RGB 编辑同一套做法)。
- `MenuItem` 新增 `value`(非编辑态显示值)与 `filter`(`EditFilter`)。
- `MenuResult` 新增 `editCommit` / `editText`;`App::DispatchMenuResult` 在 `editCommit`
时调 `PlotView::ApplyLaneValueText()`,再 `RefreshPlotMenuValues()` 回写真实(可能被夹取的)值。
- `PopupMenu` 自带 `TextEdit m_edit` + `m_editIndex`,并负责 `OnChar/OnEditMouseDown/Move/Up`;
Application 在菜单打开分支里把 WM_CHAR 与输入框内的鼠标事件路由给它,
并在输入框内按下时 `SetCapture` + `m_activeTarget = Target::EDIT_FIELD`。
- `PopupMenu::UpdateItemValue/UpdateItemLabel(id,label,checked,enabled)` 改完会 **`RebuildLayout()`**
(用缓存的 `m_buildBounds/m_buildX/m_buildY`),这是"菜单开着也能刷新显示"的关键。
- **`ApplyLaneValueText` 必须先把当前自适应范围固化进 `Lane`**(`lane.autoRange=true` 时先
`lane.yMin/yMax = visual.yMin/yMax`),再改被编辑的一端;否则另一端会回退到
`Lane` 的初始占位值 `-1`,表现为"量程被莫名放大"。
- **`选区 Y 轴自适应`(`PlotView::AutoRangeLaneToView`)**:只统计 `ViewMin..ViewMax` 内的采样点,
时间单调时二分定位区间,非单调时全扫并按区间过滤;结果写入 `lane`(`autoRange=false`)。
- **菜单项里的内联编辑框必须锚定在被点击的那一行**:`MenuResult` 有 `itemRect`
(被点行的客户区矩形,在 `CommitHover`/`OnLButtonUp` 里设置);
`PlotView::BeginEdit(target, laneIndex, initialText, anchorRect = gd::RectF())` 在有 `anchorRect` 时
用它定位(`EditFieldRect()` 对 `LANE_Y_MAX/LANE_Y_MIN` 用 `m_editFieldRect` 并夹在 `m_area` 内),
才不会又画到网格左上角。**注意** `m_editFieldRect` 只在传了 `anchorRect` 时才重新设置,
非信号名类编辑不要清空它,否则 Y 轴编辑框会错位。
- **时间轴自适应到单个网格**:`PlotView::FitTimeToLane(lane)`,
取该网格内信号所属文件的时间范围,并把 `ZoomMode` 复位为 NONE。
- **光标吸附数据点**:`PlotView::SnapTimeToSamp6 逻辑像素)时吸附到最近采样点;
仅在 `CURSOR_MOVE` 拖拽中使用。缩小视图后不吸附(自由移动)——这是刻意行为,勿"修正"成始终吸附。
- **`real.ps1` 已扩展**:`key:left|right|up|down|del|home|end`(也支持 `0x..` 或十进制 VK)、
`ctrlkey:c|a|v|x`、新增 `shiftkey:left|right|home|end|del`(Shift+组合键)。
**多文件参数用 `;` 分隔**(用 `,` 会被当成单个文件名 → 界面显示"0/0 已选")。
- 验证"整段选中"态:双击进入编辑后截图应看到整段反白;
Shift+Left 收缩选区(Shift+Right 在光标已在末尾时不会变化,别误判为失效)。
- **验证选区语义的可靠手法**:不要只看高亮(小字下容易看不出)——
用 `shiftkey:left`×N 造选区后按 `key:left`(应收拢到选区左端)再 `type:X`,
由 X 的插入位置反推行为;`[0]Resample Current (mA)` 是最方便的试验文本。
- 输入框逐项验证清单(本轮已全部通过):`type:AB;key:back` → 只剩 `A`;
`dblclick` 后 `rclick` 再 `type:Z` → 追加到末尾(`…(mA)Z`);
`dblclick;key:end;shiftkey:left×3` → 末尾 3 字符反白(依赖 End 收拢选区的修复)。
- 标签栏重命名:`dblclick` 标签后 `type:` 长名称,标签应**随输入变宽**(截图上能看到完整名称);
再 `key:enter` 提交后应被 200px 上限省略为 `…`(正常行为,不是缺陷)。
- 样式跨视图同步:视图 1 里改色/透明度 → `click` 标签栏「+」新建视图 → 勾同一信号 →
两个视图的样式区应完全一致(颜色高亮环、透明度百分比、线宽数值都相同)。
- "清空后重新自适应":`click` 信号 → `click:时间轴缩放` → `pressdrag` 框选缩放(时间轴应变为子区间)
→ `click:清空网格` → 再 `click` 同一信号 → 时间轴应回到完整范围。
注意「清空网格」在最右端(本轮实测 client x≈1621),点错会落到「拆分网格」而看不出效果。
- **内联输入统一为 `PlotEditTarget`(NONE/CURSOR_TIME/LANE_Y_MAX/LANE_Y_MIN/SIGNAL_NAME)**:
`BeginEdit/EndEdit/OnEditChar/OnEditKey`,入口判断用 **`PlotView::IsEditing()`**
(`m_cursorEditing/m_cursorEditText/BeginCursorEdit/EndCursorEdit/IsEditingCursor` 均已删除),
画框统一走 `PaintEditField` + `EditFieldRect`(光标时间框在顶部、网格上下限框在网格内侧左上)。
提交受约束:`yMax > yMin`(否则按 `minimumGap` 夹取)。
- **时间光标(当前实现,详见下方专节)**:三态循环(无/单/双),时间读数在**绘图区底部**,
取值直接标在数据点上。`ToggleCursor` 已删除,改用 `CycleCursorMode()`,
状态是 `TabConfig::CursorMode()`。
- **光标形态**(`PlotView::QueryCursor`):默认与悬停线外一律箭头(**不再有十字指针**);
`RUBBER_SELECT` → 轴向未定时 `IDC_SIZEWE`、确定为纵向时 `IDC_SIZENS`;`CURSOR_MOVE` → `IDC_SIZEWE`;
`PAN`/`MOVE_SIGNAL` → `IDC_SIZEALL`;**常规模式下贴近信号曲线**同样返回 `IDC_SIZEALL` 作为可拖拽提示。
用 `.preflight/cursor.ps1` 探测验证。
### 选区放大(工具栏单一按钮)
- 工具栏只有一个 **`选区放大`**(`ToolbarAction::ZOOM_SELECT`,`PlotZoomMode::SELECT`);
旧的「时间轴选区」「Y 轴选区」两个按钮与 `PlotZoomMode::TIME/VALUE` **已删除**。
- 按下时进入 `PlotDragMode::RUBBER_SELECT`,**轴向由首次明显位移(>4 逻辑像素)决定**并固定:
`|dx| >= |dy|` → 时间轴选区(竖向贯穿全高),否则 → 该网格的 Y 轴选区。
轴向未定时不画选区,避免刚按下就闪一个方框。
- **框选过程中右键 = 放弃当前选区 + 显示提示**(`Application::WM_RBUTTONDOWN` 先判
`m_plot.IsRubberSelecting()` → `CancelDrag(true)`),**不退出选区放大模式**、不弹菜单。
因此 `WM_RBUTTONUP` 必须**在左键仍按下时不 ReleaseCapture / 不清 `m_activeTarget`**
(否则松左键时 `OnLButtonUp` 收不到,`m_dragMode` 会永远停在 RUBBER_SELECT)。
- Esc 仍然同时「放弃拖拽 + 退出选区放大模式」(与右键语义不同,用户明确要求保留)。
### 时间光标(单/双光标 + 数据点取值标注)
- **三态循环**:`TabConfig::CursorMode()` = `PlotCursorMode{NONE,SINGLE,DOUBLE}`,
工具栏「光标」按钮调用 `PlotView::CycleCursorMode()` 循环 无→单→双→无。
`CursorEnabled()` 现在是**只读派生**(!= NONE),不要再写成可写引用。
- 两条光标的时间:`TabConfig::CursorTime()`(光标 1)与 `CursorTimeAlt()`(光标 2)。
颜色:光标 1 = `theme.cursor`(Solarized 橙 `#CB4B16`),光标 2 = `theme.cursorAlt`(青 `#2AA198`),
取色走 `PlotView::CursorColor(which)`。光标 2 的顶部把手画成朝上的三角以便区分。
- 由单切双时,光标 2 首次落点 = 光标 1 时间 + 视图跨度的 12%,越界则反向,并夹在可见区间内。
- **⚠️ 关键缺陷修复:光标必须在视图变化后夹回可见区间**(`ClampCursorToView()`)。
原先缩放/平移时间轴后光标会落到视图之外,`PaintCursor` 直接 return → 用户看到"光标丢失、
放大后无法拖动"。现已在 `ApplyTimeSelection` / `FitAll` / `FitTimeToLane` / `PAN` 平移中调用;
`CURSOR_MOVE` 里也把目标时间夹在 `[ViewMin, ViewMax]` 内。
- **命中测试**:`IsOnCursorLine(x, int& which)` 输出命中的光标序号(容差 6 逻辑像素,
重叠时优先光标 1);拖拽时用 `m_hoverCursor` 决定写哪一条。
- **时间读数在绘图区底部**(`CursorLabelRect(which)`,`top = plotRect 底 - 高 - 2*scale`),
单/双光标**都始终显示**;`EditFieldRect()` 的时间输入框落在同一位置(编辑 `m_editCursor` 指定的那条)。
- **取值标注画在数据点上**(`PaintCursor` 内):跨过光标的每条可见信号都在其 `(x, y)` 处标一个
信号色圆角小胶囊(底色 = 信号色、文字 = `IsDarkColor` 决定的深/浅色),
右侧空间不足时标到左侧。**旧的网格右上角"读数框"(`PaintLaneReadout(s)`)已删除,不要再加回来。**
- 双光标时以时间较小者为"左",**右侧光标**的文案是 `当前值 , ±增量`(增量 = 右 − 左);
增量为负时用 `-` 前缀而不是 `±-`(避免双符号)。
- **提示浮层**(`m_hintText` + `PaintHint`):切模式/右键放弃选区时在绘图区底部显示一行提示,
光标启用时自动上移一行避免压住时间读数;**在网格内再次移动鼠标即自动清除**(无需定时器)。
### Esc 语义(用户明确要求)
- `VK_ESCAPE` 分支现在做两件事:
`bool changed = m_plot.CancelDrag(); changed = m_plot.ExitZoomModes() || changed;`
1. **放弃当前拖拽**(`PlotView::CancelDrag()`):复位 `m_dragMode/m_rubberRect/m_dragMoved/
m_dragSignal/m_dragTargetLane` 且**不改动视图范围**;调用方仍持有鼠标捕获,
松开左键时模式已是 NONE,选区不会落到时间轴/幅值轴上。
2. **退出全部缩放模式**(`PlotView::ExitZoomModes()` → `Tab().ZoomMode() = NONE`),
使工具栏「时间轴缩放」「Y 轴缩放」回到未选中态(用户要求"网格内按 Esc 退出缩放按钮选中")。
- **Esc 不与时间光标关联**:`DisableCursor` 已从 Esc 移除,
关闭光标只走工具栏「光标」按钮。**不要把 DisableCursor 加回 Esc。**
- `HandleKeyDown` 返回 false 时 `WM_KEYDOWN` 落到 `DefWindowProc`(无害)。
## 界面信息取舍(用户明确要求)
- **没有底部状态栏**(`PaintStatusBar/m_statusRect/kStatusBarHeight/kStatusHint` 均已删除),
内容区一直延伸到窗口底部。`Application::SetStatusMessage(text, isError)` **只在 isError 时弹 MessageBox**,
普通提示一律忽略——因此不要再往状态栏加统计/快捷键提示。
- **标题栏只显示应用名称**(`UpdateWindowTitle()` 固定写 `kWindowTitle`)。
- 工具栏**不显示视图时间范围文本**(`Toolbar::SetStatusText/m_statusText` 已删除)。
- 工具栏按钮:**「导入文件」固定在左,其余按钮整体靠右**(先测量全部宽度,再 `rightEdge - groupWidth` 定位)。
按钮标签为 **导入文件 / 适应视图 / Y 轴自适应 / 选区放大 / 光标 / 合并网格 / 拆分网格 / 清空网格**
(`ToolbarAction::SELECT_ALL`「全选信号」已删除;CLEAR_ALL 文案为「清空网格」;
「时间轴缩放/Y 轴缩放」两个按钮已合并为「选区放大」;「Y 轴自适应」与
「光标」(三态)都是 toggle)。
## 工具栏
- 按钮集合:导入文件 / 适应视图 / **Y 轴自适应** / 时间轴选区 / Y 轴选区 / 光标 / 合并网格 / 拆分网格 / 清空网格。
其中 **Y 轴自适应(`ToolbarAction::AUTO_FIT_Y`)、时间轴选区、Y 轴选区、光标为 toggle**
(`Button::toggle/checked`,选中态 = `accentSoft` 底 + `accent` 描边)。
- **「Y 轴自适应」与网格右键菜单的「选区 Y 轴自适应(全局)」是同一个开关**:
`Application::ExecuteToolbarAction` 用 `AllLanesAutoFitToSelection()` 取反后
`SetAllLanesAutoFitToSelection()`;`Render()` 每帧 `SetToggleState(AUTO_FIT_Y, …)` 同步。
- `Toolbar::SetToggleState(action, checked)` 在 `Application::Render()` 每帧按当前标签页配置同步,
不要在各交互分支里手工维护按钮状态。
- 按钮是**整体右对齐**的:新增按钮会让左侧按钮整体左移,改按钮集合后**必须重新量坐标**。
实测(125% 缩放、面板默认宽度、窗口 1725x996,客户区 y=74):
适应视图 ≈948 / Y 轴自适应 ≈1074 / 选区放大 ≈1200 / 光标 ≈1299 /
合并网格 ≈1399 / 拆分网格 ≈1517 / 清空网格 ≈1636。
**换算公式:客户区 = 截图坐标 − (9,38)**。
量坐标的可靠手法:`.preflight/scanline.ps1 -Y 96 -X0 650 -X1 1724 -MinRun 3`
(在按钮上沿扫描,7px 的窄 Run 就是按钮之间的间隙,取相邻两间隙的中点为按钮中心;
Y=100/115 会扫到文字导致 Run 被打碎,不要用)。
## 浮动拾色器(ColorPickerPopup)
- `Open(bounds, anchorRect, color)` 以**触发器矩形**为锚点:优先紧贴其下方、空间不足改到上方,
左右与之对齐——**不要**用单点作锚点,否则翻转时会遮住触发控件。
- 打开优先级:弹出菜单 > 拾色器 > 常规组件。拾色器打开时:
- 点击**拾色器内** → `SetCapture` 并 **consume 该次 WM_LBUTTONDOWN**。
**必须消费**,否则会穿透到下方图层(绘图区平移/框选,或列表选中)——这是曾经的缺陷。
- 点击面板内其它控件 → **先收起拾色器再放行该次点击**(一次点击即可切换预设色);点击面板外/绘图区 → 仅收起。
- **光标**:`QueryCursor` 在滑块(或拖拽中)返回 `IDC_SIZEWE`,其余返回 `IDC_ARROW`;
Application 在拾色器打开时用专用 `WM_SETCURSOR` 分支处理(否则会落到常规分支显示箭头)。
- **颜色记忆**:`Open` 以 `SignalStyle::customColor` 为初值;`ApplyColor(color)` 同时写 `color` 与 `customColor`;
点击加号方格按下时先 `style->color = style->customColor`(即便随后收起浮窗也生效),抬起仍命中方格才 `Open`。
- 取色变化经 `TakeChanged()` 拉取后由 Application 调 `SignalPanel::ApplyColor()` 落到当前信号。
## 信号名与分组名(可重命名)
- `SignalData::name` = 列名原文(`Trim` 后),**保留 `[n]` 前缀与 `(mA)` 等括号内容**,
不再剥离/改写(`MakeDisplayName`/`StripBracketSuffix` 已删除);`unit` 仅额外解析备用。
- **展示名可被用户覆盖**:`SignalData::displayName` / `DataFile::displayName`,
界面一律读 **`Title()`**(为空则回退 `name` / `fileName`)。
→ 波形图例、光标读数、菜单标题、列表行、状态提示都必须用 `Title()`,勿直接用 `name`/`fileName`。
- 写入接口:`DataSet::SetSignalDisplayName(fileIndex, signalIndex, name)` /
`SetFileDisplayName(fileIndex, name)`(传空串即恢复原名)。
因此 `SignalPanel::SetContext` 收 **`DataSet*`(可写)**,同时另存 `m_dataSetWritable`。
- **入口是双击**(不再是切换可见性):
- 双击信号行**行主体** → 重命名信号;复选框与 G 徽标双击不处理。
- 双击分组行的**名称区域** → 重命名分组;双击**折叠三角**仍是折叠。
- 行内编辑框位置由 `RowNameRect(row)` 计算(分组行会跳过「组X」标签与分隔线宽度 `FileGroupLabelWidth`)。
- 行内空间不足仍会省略号截断,若需完整显示可加宽面板。
## 文件加载(大文件、异步、内存)
- **解析改为流式分块读取**(`CsvTable.cpp` 里 4MB staging + `pending` 行缓冲),
不再把整个文件读入内存,**也没有体积上限**(原先 `fileSize > 512MB` 直接拒绝,已删除)。
用 `_fseeki64/_ftelli64`(`long` 在部分平台只有 32 位,处理大文件会失真)。
- **`CsvTable` 是列优先的**(`columns[列][行]` + `rowCount`),**不要再改回行优先二维数组**:
百万行会产生上百万次独立堆分配,每行还有 vector 控制块与容量对齐开销,实测内存放大近 1.5 倍;
列优先还能让每列**整块移动**进 `SignalData::values`(零拷贝)。
- `CsvTable::fileBytes` 用于按比例 `reserve` 行容量(`EstimateRowCapacity`,上限 4.19M 行)。
- 接口分工(为多线程准备):`BuildDataFile(CsvTable&&, path, paletteBase, DataFile&, error, progress)`
是**不访问 DataSet** 的自由函数(可在线程上跑);`DataSet::AddPreparedFile(DataFile&&, int&)`
只做一次移动;`DataSet::AddFromTable` 退化为"复制表格 + 走同一流程"的兼容入口。
- **加载在工作线程执行**(`Application::LoadFileAsync` + `RunAsyncLoad`,`std::thread`):
主线程用 `PeekMessage` 抽送消息 + `InvalidateRect/UpdateWindow` + `Sleep(15)` 播放
`PaintLoadingOverlay`(旋转弧线 + 进度条 + 文件名 + 百分比 + 「按 Esc 取消」),
加载期间**屏蔽鼠标与其它按键**(保留 Esc 取消、WM_QUIT 重新投递),完成后主线程 `AddPreparedFile`。
- 进度:解析占 0-75%,构建占 75-100%;0% 表示"未开始",所以遮罩只在真正进入加载后显示。
- `CsvProgress` 回调返回 false 即取消;取消时**不要弹错误框**(静默返回)。
- ✅ **本工具链(mingw64 win32-seh + `-static`)的 `std::thread` 是真正并行的**
(实测 2 线程 1.92x 加速,无需额外 `-lmcfgthread`)。
- **`TryParseDouble` 有十进制快速路径**(`Utf8Util.cpp`):ucrt `strtod` 实测 **335ns/次**,
手写路径 **13ns/次(快 25 倍)**;这是大文件解析的真正瓶颈(几千万次调用)。
- 非十进制写法(十六进制浮点、inf/nan、带尾部杂字符如 `"12abc"`)**原样交回 `ParseDoubleSlow`**,
接受语义与改造前完全一致。
- 有效位数 > 17 或指数超出 ±22 也回退 strtod;`mantissa` 超 2^53 时会比 strtod 多一次舍入,
相对误差 ≤1 ULP(实测 ≤2.3e-16),对显示(约 6 位有效数字)不可见。
- → 640MB 测试文件加载时间由约 **40s 降到 2s 以内**。
- 实测内存:51MB 的 BIG_DATA 峰值约 **126MB**(数据本身 113MB);
640MB 的 HUGE_DATA 载入并全选后工作集约 **566MB**。旧路径(整文件缓冲 + 行优先 + 列拷贝)
峰值约为数据量的 2.8 倍。
- 验证脚本:`.preflight/mem.ps1`(工作集/私有内存,从进程创建起就采样峰值)、
`.preflight/loadshot.ps1`(加载过程中按间隔连拍,用于确认过渡动画)、
`.preflight/make_big_csv.ps1`(用 Add-Type 内联 C# 快速生成大 CSV)。
## 应用图标(重要)
- 窗口类 `hIcon/hIconSm` 与 `WM_SETICON` **必须用 `LoadAppIcon()` 从资源加载**:
`LoadImageW(GetModuleHandleW(nullptr), MAKEINTRESOURCEW(IDI_APP_ICON), IMAGE_ICON, cx, cy, LR_DEFAULTCOLOR)`,
`cx/cy` 取 `SM_CXICON/SM_CYICON`(大)与 `SM_CXSMICON/SM_CYSMICON`(小)。
- **不要再用 `LoadIconW(nullptr, IDI_APPLICATION)`**——那是系统默认图标,
会让标题栏、任务栏、Alt+Tab 缩略图全部显示成"通用应用",即用户报告的"缩略图/标题栏图标不正确"。
- `Resources/Inc/ResourceIds.h` 里的 `IDI_APP_ICON = 1`;Application.cpp 需 `#include "ResourceIds.h"`
(`Resources/Inc` 已在 `CXX_INCLUDES_PATHS` 中)。
- `Resources/JustDataInspector.ico` 是多尺寸资源(256/64/48/32/16),系统会自动挑最接近的一帧。
## 文件分组(左侧列表)
- 分组标题格式 **`组X` + 绘制的竖线 + `<分组名>`**(`PaintFileRow`):
序号 `fileIndex + 1` 用 accent 色,**竖线用 `UiFillRect` 绘制而非 `|` 字符**
(常量 `kGroupSeparatorGap/kGroupSeparatorWidth`),随后是 `Title()`,右侧为「N 项」与 `✕` 关闭按钮。
- 序号按**当前位置**连续编号 → 关闭某个分组后其余分组自动重新编号(无需维护额外状态)。
- **同一文件允许多次加载**:`Application::LoadFile` 不再查重(`DataSet::IndexOfFile` 已删除),
每次加载生成独立 `DataFile`(各自持有完整采样副本),归入不同分组。
- `DataSet::RemoveFile` 用「依次前移 + `pop_back` + `shrink_to_fit`」而非 `erase`,
使被移除文件的时间轴与信号缓冲区在本次调用内析构、容量一并归还。
- **加载后不依赖原始文件**:`CsvTable::ReadWholeFile` 在一处 `fread` 后立即 `fclose`(所有失败分支也都关闭),
数据整份驻留内存;已实测删除源文件后仍可正常勾选与绘制波形。
## 标签页命名
- `Application::NextTabName()` 取**当前未被占用的最小编号**(而非自增计数器),关闭后可复用。
## 标签栏宽度(TabBar::Layout)
- **按加粗字体测量**(`max(UiMeasureTextWidth(uiBold), UiMeasureTextWidth(ui))`):
激活页用 `uiBold` 绘制,若按常规字体测量,长名称在激活时会被提前省略(**预览过短**)。
- **重命名时按 `m_edit.Text()` 测量**(而不是已提交的 `m_tabs[i].name`)——
否则标签宽度不会随输入增长,长名称编辑时只能看到滚动后的一小截(**长内容绘制错误**)。
上限用 `kEditingTabWidth=320`(普通 200),并要求 Application 在
`WM_LBUTTONDBLCLK`(进入编辑)与 `WM_CHAR`(每次输入)后调用 `Relayout()`,退出编辑时也调用。
- 标签总宽超出可用宽度时整体等比收缩(`shrink = available/total`,下限 `kMinTabWidth`),
并把 `m_addRect` 的右边界钳到区域内 → **「+」始终可见**。
## 绘图区标尺与网格(可读性)
- 幅值刻度目标数 = `clamp(round(lane.rect.Height / (kYTickSpacing*m_scale)), 3, 8)`
(`kYTickSpacing=28`)。所有网格高度相同 → 刻度数量一致,避免"有的网格 1 条标签、有的 3 条"。
网格很矮时靠 `kYTickTargetMin=3` 保证仍有可读的密度(用户要求"区间过小时增加密度")。
- 标签行距 = `min(kYLabelMinGap*m_scale, lane.rect.Height/(yTickTarget+1))`——
网格矮时同步收紧,否则标签会被整片过滤掉。
- 主题色:`gridWeak=#DDD3B8`、`gridStrong=#C3B896`(偏暖加深),
与网格边界的蓝灰(`axisLine/borderStrong=#93A1A1`)保持明显区分。
五等分辅助线用 `WithAlpha(gridWeak, kMinorLineAlpha=0x9A)`。
- 时间轴列名补单位:若 `timeColumnName` 不以 `)` 结尾则追加 ` (s)` → 显示 `time (s)`。
## 验证手段(无自动化测试)
- **优先用真实输入**:`.preflight/real.ps1` 用 `SetCursorPos` + `mouse_event`/`SendInput` 产生真实鼠标键盘消息。
凡是与按下/松开、捕获、焦点、菜单相关的交互**必须**用它——`PostMessage` 会掩盖这类缺陷
(徽标菜单"按住才显示、松手即消失"就是只在真实输入下复现的)。
- `PostMessage` 方案(`.preflight/actions.ps1`)仍可用于纯悬停/滚轮/拖拽等不涉及捕获语义的场景,速度更快。
- `SendMessage` 方案(`.preflight/act.ps1`,会把窗口钉到 (0,0) 使坐标稳定)动作语法:
`move:x,y` / `click:x,y` / `rclick:x,y` / `dblclick:x,y` / `wheel:x,y,notches` / `key:VK` / `text:字符串` /
`drag:x,y,dx,dy`(按下→移动→松开)/ `draghold:x,y,dx,dy`(按下→移动,**不松开**,用于截取拖拽中间态)/ `wait:ms`。
**`text:` 逐字符发 WM_CHAR**——`SendMessage` 绕过 `TranslateMessage`,只发 `key:` 永远产生不了字符,
验证文本框输入必须用 `text:`(`real.ps1` 则用真正的 `SendInput` 扫描码,等价于真人键入)。
- **`act.ps1` 的坐标陷阱(重要)**:它的动作参数**按截图坐标书写**但**直接把该数值当客户区坐标发给窗口**,
而窗口被钉在 (0,0),所以窗口内控件需要 `客户区坐标 = 截图坐标 - 边框偏移(9,38)`。
面板行高 27–30px 时这个 38px 偏移常"碰巧"仍落在目标行上(勾选信号就是这么蒙对的),
但**绘图区/网格类的精确点击一定会差一行**(曾在 y 差 38px 时命中了相邻网格)。
→ 涉及绘图区/网格/曲线的交互,务必先减去 `(9,38)` 再传参。
- `real.ps1` 的拖拽动作叫 **`pressdrag:x,y,dx,dy`**(不是 `drag`);`drag:` 会静默落空。
另有 **`down:x,y`** / **`up:x,y`**(单独发送按下/抬起,用于截取"按住不放"的中间态)与
**`ctrlkey:c|a|v`**(Ctrl+组合键,验证 Ctrl+C 复制用)。
`real.ps1` 的动作写**客户区坐标**,`act.ps1` 写**截图坐标**。
- `real.ps1` 的 **`key:` 只接受名称**(`enter`/`esc`/`back`/`tab`),传数字 VK **不会发任何按键**(
曾误用 `key:13` 导致"回车提交不生效"的假象);`act.ps1` 的 `key:` 才用数字 VK。
- `real.ps1` 的 `wheel:` 负增量需按 DWORD 回绕:
`[System.BitConverter]::ToUInt32([System.BitConverter]::GetBytes([int32]$raw), 0)`
(直接用 `[uint32]-120` 会抛"值对于 UInt32 太大或太小")。
- `cursor.ps1 -Actions ... -ProbeX -ProbeY` 用 `GetCursorInfo` + `LoadCursorW` 比对光标形态,
传**客户区**坐标,返回 `ARROW/SIZEWE/SIZENS/...`——验证"光标随模式变化"必须用它。
- `crop.ps1` 的裁剪矩形超出图像边界时报 `内存不足`(GDI+ 用该错误表示非法矩形)——
先核对 `X+W <= 图宽`、`Y+H <= 图高`(截图高度≈996,取 Y=786/H=200 就会越界)。
- 坐标空间:截图为**整窗**(含标题栏+边框);`real.ps1` 按 **客户区坐标** 书写动作,内部用
`ClientToScreen(0,0)` 自动换算;`actions.ps1` 按**截图坐标**书写。本机(125%)边框偏移 `(9,38)`。
- 辅助脚本:`crop.ps1`(裁剪放大)、`pixel.ps1`(取色)、`vscan/scanline`(像素游程定位控件)、
`cursor.ps1`(用 `GetCursorInfo` + `LoadCursorW` 比对判断当前光标形态,验证光标类需求)、
`resize.ps1`(自动 `MoveWindow` 多档缩放 + 可选 `-SelectPoints "x,y;…"` 先勾选信号,
用于验证缓存区域/布局在窗口尺寸变化后是否错位)。
- ⚠️ **测试脚本不一定会结束进程**(`resize.ps1` 就会留着):重建前若报
`cannot open output file Build/Release/Target.exe: Permission denied`,
先 `Get-Process Target | Stop-Process -Force` 再 `make`。
- **空闲 CPU 测量**:`.preflight/idle.ps1`(本仓库脚本,.preflight 已被 gitignore)——
自己启动进程、用 SendInput 执行 `click/wait` 动作建立场景,把鼠标停到不触发悬停的位置后
取 `Process.TotalProcessorTime` 的 5 秒增量并换算成“单核百分比”。
实测 10 网格 × 128 万采样时为空闲 **0.00%**。
- **内存测量**:`.preflight/mem.ps1`(工作集/私有内存;**从进程创建起就采样才能拿到加载峰值**)。
- **像素级 A/B 对比**:`.preflight/hashregion.ps1 -In x.png -X -Y -W -H` 输出指定区域的 SHA256。
验证"某交互不应改变外观"时,对**不受该交互影响的区域**取哈希对比最可靠
(例:拖拽光标/框选拖拽不改变视图,故波形区域哈希应完全一致)。
- **GDI+ 绘制基准脚本**(`.preflight` 下,编译命令 `g++ -O2 -std=c++17 ... -static -lgdi32 -lgdiplus`):
`scatter_bench.cpp`(逐点 FillEllipse 63ms vs GraphicsPath+FillPath 7ms vs GDI Ellipse 3.6ms,
均针对 2500 个点)、`line_bench.cpp`(4800 点折线:AA round join 41ms、AA miter 31ms、
关抗锯齿 11ms、1 点/像素列 18ms、包络带 FillPath 22ms)。
结论:**散点必须合并成一条 GraphicsPath**;**不要关抗锯齿**(用户会立刻看出"变模糊")。
- **加载动画验证**:`.preflight/loadshot.ps1`(按间隔连拍 + `InitialDelayMs` 控制起始时刻)。
要拍到遮罩必须把 `InitialDelayMs` 设小(约 250ms)且 `IntervalMs` 小于加载总时长。
- **帧耗时验证手法**:在 `Application::Render()` 开头与结尾各取一次 `QueryPerformanceCounter`,
把耗时 append 到 `.preflight/frame.log`;只测分量时在 `PaintLane` 内部打点。
⚠️ **探针里不要每条信号/每帧 `fopen/fprintf/fclose`**(10 次文件 I/O 就能给单帧加几十毫秒,
会让"绘制很快"被误判成"绘制很慢")。先累加到静态变量、每 N 帧写一行。
⚠️ `real.ps1` 的 `pressdrag` 只发 1 次 down + 10 次快速 move + 1 次 up,
实测**拖拽期间的 paint 常被合并/推迟**,日志里那几帧的 `m_dragMode` 往往是 NONE;
要测"拖拽中"的状态请用 `down` + 多个 `move`(中间插 `wait`)+ 不 `up`。
- **工具栏按钮客户区坐标**(125% 缩放、面板默认宽度、窗口 1725x996 实测,改按钮集合后需重测):
适应视图 (943,74) / 时间轴缩放 (1076,74) / Y 轴缩放 (1201,74) / 光标 (1292,74) /
合并网格 (1402,74) / 拆分网格 (1516,74) / 清空网格 (1619,74)。
**换算公式:客户区 = 截图(图像)坐标 − (9,38)**。搞错会点到隔壁按钮
(曾把「时间轴缩放」点成「适应视图」,导致“框选无效”的假象)。
- **验证"图层穿透"类缺陷**:对**不受目标变化影响的区域**做像素哈希对比(如时间轴刻度行、工具栏视图范围文本),
拖动滑块前后哈希一致即证明未穿透;直接对比绘图区会因波形变色而误判。
- **定位控件的推荐流程**:先用 `crop.ps1` 放大目标区域读出真实像素位置 → 换算 → 点击 → 再 `crop.ps1` 核对。
- 面板几何随宽度变化(默认 366 逻辑像素 → 457px),**行内右侧控件(G 徽标、✕)以及样式区所有控件的横坐标
都会随面板宽度而变**;面板宽度一改就要重新量坐标,不要沿用旧值。
- **样式区颜色行实测坐标**(125%、默认面板宽、窗口 1725x996):色块为 10 个预设 + 1 个加号方格,
间距 30px,第一个预设中心 **截图 x≈99**(客户区 x≈90),加号方格中心 **截图 x≈399**(客户区 ≈390),
行中心 **截图 y≈820**(客户区 y≈782)。`.preflight/scanline.ps1 -Y 820 -X0 70 -X1 420 -MinRun 3` 可复测。
- **信号列表复选框实测坐标**:第一行复选框中心 **客户区 (60, 206)**,行间距 ≈34.5px
(点 (54,196) 之类的偏移会落到行主体 → 只选中、不勾选,这点容易误判)。
- 面板宽度上下限:**260–400 逻辑像素**(`kMinPanelWidth/kMaxPanelWidth`),拖动分隔条会被钳制。
- 拾色器的滑块行坐标会随锚点翻转(在触发器上方或下方),定位前先截图确认。
- 复选框仅 15 逻辑像素见方且垂直居中,坐标必须取行中心附近,否则命中行主体(只选中、不勾选)。
- 坐标反复算不准时,**临时在命中分支里 `fopen/fprintf` 打点**(输出 area/rect/part/点击点),
跑一次读日志即可定位,比反复猜坐标快得多;定位后务必删除该诊断代码。
- **不成立即为"空操作"的场景**:单条可见信号下「新建网格」会把原网格回收,结果仍是 1 个网格
→ 验证菜单项要用**两条信号**(2 网格 → 移回 G1 → 合并为 1 网格)才看得出效果。
- 新数据:`TestData/[2].CSV` 由 `.preflight/make_testdata.ps1` 生成(缩放+偏移+抽稀,多一列),用于多文件对比测试。
- **验证"无原始文件依赖"**:`.preflight/independence.ps1` 把数据拷到临时文件 → 启动程序 → 删除该文件 →
再勾选信号并截图;能正常出波形即证明加载后不再引用源文件。
## Git 提交(本机陷阱)
- 本仓库已设 `user.name=DeepSeek V4.1 Flash`、`user.email=<>`(仅本地生效,勿动全局)。
- **提交信息用 `-F 文件` 提交**(多行 `-m` 会被 PowerShell 截断)。文件用无 BOM 的 UTF-8 写入:
**PowerShell 5.1 不支持 `Set-Content -Encoding utf8NoBOM`**(该值会静默失败导致提交流程中断),
必须用 `[System.IO.File]::WriteAllText($path, $text, (New-Object System.Text.UTF8Encoding($false)))`。
- `git log` 在 PowerShell 里显示中文为**乱码是显示假象**:PowerShell 用控制台代码页解码 git 的 UTF-8 输出。
提交对象里其实是正确的 UTF-8。**核对方法**:`cmd /c "git cat-file commit HEAD > raw.txt"` 再用
`[System.Text.Encoding]::UTF8.GetString(bytes)` 解码,看字节是否为 `228,191,174`("修")而非 `230,183,135`("淇")。
设置 `LC_ALL=C.UTF-8` 对 `git commit -F` 无效。
- **不要用终端命令往文件里追加中文**:`run_in_terminal` 里写的非 ASCII 字符会被二次编码,
落盘成乱码(实测给 `.gitignore` 追加 `# 测试数据` 变成 `# 娴嬭瘯鏁版嵁`)。
给已有文件加中文注释/文案一律用文件编辑工具(edit/replace)而不是 PowerShell 命令。
### 历史重写(剔除 TestData,已完成)
- 目标终态:只有 `main` 分支(`master` 已删除)、全部历史不含 `TestData/`、
`TestData/` 在 `.gitignore` 中、磁盘上 3 个文件完好。
- 做法:`$env:FILTER_BRANCH_SQUELCH_WARNING=1` +
`git filter-branch --force --index-filter "git rm -r --cached --ignore-unmatch TestData" --prune-empty --tag-name-filter cat -- --all`
→ 删 `refs/original/*`(`git for-each-ref --format="%(refname)" refs/original/ | % { git update-ref -d $_ }`)
→ `git reflog expire --expire=now --all` → `git gc --prune=now`
→ `git branch main` / `git checkout main` / `git branch -D master`。
- **⚠️ 最大的坑**:`--index-filter` 虽然只改索引,但 **`filter-branch` 收尾会做一次工作区更新
(等价 `git read-tree -u -m`/checkout)**,把已从历史剔除的路径**从磁盘一并删掉**。
实测 `TestData/[1].CSV` 与 `[2].CSV` 在重写后从工作区消失。
→ **重写前必须先把要保留的文件复制到仓库外目录**,重写后 `Copy-Item` 回来并用
`Get-FileHash -Algorithm SHA256` 逐个校验(`BIG_DATA.csv` 有 49MB,虽未被追踪也一并备份)。
- `git log --all --name-only -- <path>` 在 `refs/original/...` 还在时**仍会列出该路径**,
别据此判断"没删干净";先删 `refs/original` 再复查(此时应无任何输出)。
- `git checkout`/`git branch` 的提示走 stderr,PowerShell 会渲染成 `NativeCommandError` 红字
——那是正常输出(`Switched to branch 'main'`),不是失败。
- 本次改动后的历史:15 个提交,`main` 指向 `12b3531`(原 `b229781`,因 `.gitignore` 注释修正 amend 过)。
- 仓库外备份 `_TestData_backup` 已按用户确认删除(删除前核对过内容与 `TestData/` 逐一相同)。
- 当前 `main`:`d6a8183`(性能优化 + Esc 语义),之后 **`45b44ea`**(网格菜单重构 + 异步加载 + 解析优化 + 图标修复),
再之后 **`04e98ee`**(Esc 退出缩放模式 + 移除拖拽降级 + 粗粒度包络层 + 散点批量化),
再之后 **`a3e638f`**(默认线宽 1.0px + 菜单文本左对齐 + 散点白边修复 + 拖拽静态场景缓存),
再之后 **`dd48cdd`**(工具栏"时间轴选区/Y 轴选区" + 菜单对勾右置与边距 + 选区自适应勾选 +
静态场景缓存推广到全部交互),再之后 **`本次`**(颜色预设/自定义高亮修复 + 选区 Y 轴自适应改为
切换开关(含全局开关与工具栏「Y 轴自适应」按钮)+ 菜单项改名「按网格时间轴展开」并移位)。
- 用户已在 `TestData/` 放入 **`VERY_BIG_DATA.CSV`(1.77 GB,5 信号)** 用于压测;
我生成的 `HUGE_DATA.csv` 已被用户清理(磁盘上只剩 `BIG_DATA.csv`/`VERY_BIG_DATA.CSV`/`[1].CSV`/`[2].CSV`)。
- 后续提交:`eaf5b9c` → `ed13aa7` → `a566d98` → `cd6e0ed` → `b884e7e` → `5182bd8` → `82a2936` → **`fe72afc`**
(上下越界数据点三角 + 中心轴曲线/取值跟随原信号样式 + 拆分项信号数尾缀 + 信号筛选框 +
稠密区域拖拽性能修复)。
## 稠密波形绘制(fe72afc,重要性能约定)
- **采样密度超过像素分辨率时(`visibleCount > budget`)必须走「包络带填充」而不是锯齿描边**。
旧做法是每列 min/max 两个顶点交替,形成 2000+ 条近乎满幅的斜线段;
GDI+ 抗锯齿光栅化成本 ∝ 覆盖像素数,实测 **14.26ms/网格**(5 网格 ≈70ms/帧,就是拖拽卡顿主因)。
现做法:逐列记录上/下包络(各外扩半个线宽,保证细波动也有可见粗细),
收段时拼成「上包络正向 + 下包络反向」的闭合多边形,一条 `GraphicsPath` + `FillPath` 绘制。
基准(1224 列):**14.26ms → 1.69ms(8.4 倍)**,**保留抗锯齿**,静态与拖拽帧一致。
⚠️ **必须 `FillModeWinding`**(包络自交,交替规则会挖洞)。
`PolylineCacheEntry::band/bandBreaks/bandMode` 存这两套数据;`points`(锯齿)仍保留给
`HitTestSignal` 用(它假设每段 x 单调,包络带的回折部分会破坏该假设)。
MOVE_SIGNAL 虚影同样要用包络带。
- **`UiFillRect` 在面积 ≥ `kGdiFillAreaThreshold`(20000px²) 且颜色不透明时走 GDI `FillRect`**:
GDI+ `FillRectangle` 约 2.2ns/像素(整窗背景 1.7M 像素 0.9ms、面板背景 1.9ms)。
半透明必须留在 GDI+;**注意 GDI 绘制不受 GDI+ 裁剪区约束**,
所以只对"已经夹在可视区内的大矩形"启用(面板行高亮约 15kpx² 低于阈值,仍走 GDI+)。
- **`UiTextWidth` 复用 `UiMeasureTextWidth`**(`GetDC(nullptr)`),不再调用 `Graphics::GetHDC()`;
`UiEllipsize` 逐字符回退时每步都会测量,走 GetHDC 会反复触发 GDI+ 批次刷新(每次约 5µs)。
- 帧耗时打点定位法:在 `Application::Render` 记 bg/chrome/plot、在 `Render` 内分
tab/toolbar/panel、在 `PaintLane` 内分 grid/wave、在 `Paint` 内分 vis/lanes/cap/over。
**结论:面板 ≈10ms、绘图区从 47~87ms 降到 3.6~4.5ms、整帧 196.8ms → 17~21ms。**
- **面板行坐标会随新增控件整体下移**:加入「筛选信号」输入框后,复选框客户区
(61,250) 起、行距 33(此前是 (60,206)、行距 34.5)。
- **工具栏按钮客户区中心(当前布局,光标按钮文案会影响其后按钮位置)**:
适应视图 948 / Y 轴自适应 1074 / 选区放大 1200 / 光标 1300 / 合并网格 **1399** /
拆分网格 **1518** / 清空网格 1636。量法:`scanline.ps1 -Y 96`(Y=96 在文字之上,
7px 的窄 Run 就是按钮间隙;**不要**用 Y=114 那种会扫到文字的高度)。
- ⚠️ **`act.ps1` 只发 WM_MOUSEMOVE 不移动真实光标** → 悬停类验证要用新增的 `hover:x,y`
动作(同时 SetCursorPos),否则 WM_MOUSELEAVE 会立刻清掉悬停态。
`act.ps1` 的 `shot:路径` 可在同一进程内中途截图,用于固定视图做 A/B 像素对比。
## 信号筛选框(fe72afc)
- 位置在信号列表上方(`m_filterRect`),空内容显示灰色占位「筛选信号」;
单击进入编辑(`m_filterEditing`),点击别处/回车结束。
- 匹配规则 `FilterMatchesSignal`:支持 `*` 通配符(等价于任意字符,因此单独的 `*` = 不过滤),
忽略大小写;**没有 `*` 时等价于"包含"匹配**(用户友好)。
- 筛选后仍按组显示,组默认展开,组内无匹配则不显示该组。
## 网格顺序与拆分(82a2936)
- **PER_SIGNAL 模式重建网格时必须按「原有网格位置」排序**:先把已分配过网格的信号按
`laneIndexOf` 稳定排序,再把尚未分配(新勾选)的追加到末尾。直接按 `visibleRefs`(文件/列序)
重建会让新勾选的信号按其列序插到中间("位置不确定")。
- `TabConfig::SplitLane(laneIndex)`:用 N 份单信号 Lane 替换原 Lane(**插入到原位置**,
因此其后方网格整体顺次后移),每份继承原网格的网格自身状态;最后统一重命名。
网格编号(GX)一律由位置推导(`laneIndex + 1`),所以左侧徽标/图例徽标/面板标题/菜单标题
自动同步,**不要**在 `Lane` 里存编号。
- 菜单项 `PLOT_CMD_LANE_SPLIT` 用 `PlotView::LaneSignalCount() > 1` 控制可用状态。
## 鼠标中键(82a2936)
- `PlotView::OnMButtonDown/OnMButtonUp` + `Application::WM_MBUTTONDOWN/WM_MBUTTONUP`,
**任何缩放模式(含选区放大)下都可自由平移**,且不改变当前模式。
- `WM_MOUSEMOVE` 的 `buttonDown` 掩码要包含 `MK_MBUTTON`,否则拖动中不刷新光标。
## 选区放大模式与图例(82a2936)
- **点击图例条目选中信号必须在选区放大判定之前**(原先只在 NONE 模式下生效 → 选区放大时
点图例会被当成框选起点)。
- 图例悬停(半高亮)在选区放大模式下同样生效:`hoverLegend = LegendAtPoint(...)` 不再受
`ZoomMode() == NONE` 限制(只有"贴曲线"的光晕/提示才限制在 NONE)。
## 面板悬停高亮(82a2936)
- `OnMouseMove` 中若鼠标落在任一实验性功能面板上,把 `m_hoverLane` 置为**该面板所属网格**
(`overEdgePanel ? panelLane : LaneAtY(y)`),效果与鼠标直接停在网格上一致。
## 中心轴样式与文案(82a2936)
- 中心平均值曲线颜色 = `CursorColor(1)`(与「边沿计数」横向光标、中心值胶囊同色)。
- 中心值标注:`数值(中心轴)`;双光标时右侧光标为 `数值(+增量 中心轴)`
(增量 = 本光标中心值 − 另一光标中心值,正值 `+`)。**不要**再写成 `中 数值`。
- 贴边(光标在可视区之外)时中心轴数据点**必须与常规数据点一样画三角**:
直接复用 `pinned` 分支,不要单独写圆点。
## 验证脚本(82a2936)
- ⚠️ **`act.ps1` 只发 WM_MOUSEMOVE 而不移动真实光标** → 真实光标在窗口外时
TrackMouseEvent 会立刻派发 `WM_MOUSELEAVE`,把悬停态清掉,**网格高亮/悬停提示测不出来**。
新增 `hover:x,y` 动作(同时发 WM_MOUSEMOVE 并把真实光标 SetCursorPos 过去)专门用于验证悬停。
- `act.ps1` 新增 `mdrag:x,y,dx,dy`(中键按下→移动→松开)。
- 绘图区右键菜单(1 条信号时)客户区坐标:Y轴最大值 391 / Y轴最小值 427 /
选区Y轴自适应 461 / 按网格时间轴展开 494 / 拆分当前网格 494→(拆分) hmm 实测
拆分当前网格 ≈ 494、实验性功能 ≈ 528、子菜单 边沿计数 ≈ 528、边沿计数(中心轴)≈ 555,
子菜单点 x ≈ 1220。**菜单高度会随项数变化而整体位移,每次先截图量一次。**
## 实验性功能浮动面板(5182bd8 起的统一约定)
- 几何统一走 `EdgePanelLayoutFor(laneIndex, centerAxis)` → `EdgePanelLayout{panel,title,close,edit,rows}`。
**新增任何面板交互都改这一个函数**,`QueryCursor` / `OnMouseMove` / `OnLButtonDown` / 绘制共用。
- **只有标题栏可拖拽**:标题栏 → `IDC_SIZEALL`;✕ 与面板主体(含底部编辑行)→ 返回 `nullptr`(箭头)。
按下主体时 `m_dragMode = NONE` 并 `return true`(消费点击,**不穿透**到平移/选区/信号拖拽)。
- ✕:悬停时用 `danger` 色高亮(底 + 叉),但**不改变鼠标光标**;点击 → `SetLaneEdgeCount/SetLaneEdgeCenterCount(index,false)`。
- 拖拽时用 `m_edgePanelDragCenter` 区分两个面板的落点写入(`edgePanelX/Y` vs `edgeCenterPanelX/Y`)。
## 边沿计数(中心轴)(5182bd8 新增)
> ⚠️ **本节已被"最新一轮"取代**:`edgeCenterSmooth` 与 `CenterCurveFor/PaintCenterCurve/CenterValueAt`
> 均已删除。现在参数与曲线完全复用均值滤波(`edgeCenterItems` + `MeanFilterCurveFor(X, N)`),
> 面板也是逐条目三排布局。以下仅保留历史脉络(颜色派生、统计符号判定、缓存键思路仍然有效)。
- `Lane` 状态:`edgeCenter / edgeCenterSmooth(默认5) / edgeCenterPanelOpen / edgeCenterPanelX/Y`,
随 `RebuildLanes` 的 `LaneCarry` 按信号继承。菜单命令 `PLOT_CMD_LANE_EDGE_CENTER = 8`。
- 中心平均值曲线 = 每个数据点与左右各 N 点的均值(窗口 2N+1,边界按实际窗口,NaN 不参与且中心为 NaN 则不画)。
用**滑动窗口求和**(O(n),无前缀和内存):`while (windowLow < low) remove; while (windowHigh < high) add;`
缓存 `m_centerCurveCache`(`std::deque`,键 = 信号指针 + N)→ 引用稳定。
`PaintCenterCurve` 用等距抽稀(平滑曲线无尖峰,不需要包络层);`centerSmooth` 在 `PaintLane` 内取值,
并已加入 `SceneContentHash`(否则开关/改 N 不会重绘)。
- **曲线颜色必须与波形区分**:波形与曲线同色时会被完全淹没。现用
`MixColor(signalColor, 黑/白, 0.58/0.62)`(按 `IsDarkColor(m_theme->plotBg)` 选择方向)+ 线宽 +0.8。
- 统计:`EdgeCenterCountsFor` 比较 `signal[i] - center[i]` 的符号变化
(由负转非负 = 上升沿,由正转非正 = 下降沿);缓存键含 smooth + 时间区间。
- 纵向光标额外标注中心曲线取值:在 `PaintCursor` 里用 `CenterValueAt` 取最近采样的曲线值,
画 `中 <值>` 胶囊(与曲线同色、alpha 0xB8)。取值胶囊避让已抽成 `PlotView::PlaceCursorLabel`。
- 面板底部固定一行「平滑点数 N」:新增 `PlotEditTarget::EDGE_CENTER_SMOOTH`,
`EditFieldRect` 把它放到 `m_editFieldRect` 行内靠右;`EndEdit` 夹在 0–1000000 并清空两个派生缓存。
- ⚠️ 校验脚本 `.preflight/centerprobe.ps1`(朴素 O(n·N) 实现,全区间与实现逐位一致);
`centerprobe2.ps1` 模拟实现的区间语义。**光标区间计数会有 ±2 差异**,那是界面上光标时间
显示值取整(6 位有效数字)造成的边界差,不是逻辑错误。
## 图例联动与场景缓存(b884e7e)
- ⚠️ **静态场景缓存会把图例的选中/悬停底色一起冻住**:任何影响图例像素的状态都必须
要么进缓存指纹(`SceneContentHash`,例如 `SelectedRef()`),要么改成贴图后绘制。
- **选中态进指纹**:`SelectedRef()` 已折叠进 `SceneContentHash`,
否则在左侧信号面板点选信号后图例不会高亮(用户报告的"图例联动失效")。
- **悬停态不进指纹**(会因每次鼠标移动都让 80ms 的整幅重绘失效):
新增 `m_hoverLegend` + `PaintLegendHighlight()`,在两条绘制路径中
(缓存贴图快路径与整幅重绘路径)都调用,用 `WithAlpha(accent,0x38)` 叠半高亮。
**新增随鼠标变化的绘制必须同时加在这两条路径**。
- `OnMouseMove` 里 `hoverLegend = LegendAtPoint(...)` 命中时**不再**赋给 `m_hoverSignal`
→ 悬停图例不画信号光晕、不弹“按住拖拽可移入其它网格”气泡,点选仍走 `SelectSignal`。
## 光标形态(b884e7e 用户明确要求)
- **悬停信号不改变鼠标形状**(`QueryCursor` 对 `HitTestSignal` 命中返回 nullptr → 箭头);
只有 `MOVE_SIGNAL` 拖拽中才是 `IDC_SIZEALL`。
## 时间轴(b884e7e)
- `PaintTimeAxis` 在主刻度之间增画 `kMinorDivisions`(=5)等分的**短子刻度**(3px,
`m_theme->axisLine`),与主刻度重合处跳过。
## 边沿计数(b884e7e 重做判定规则)
- 判定 = **穿越横向光标的幅值 `Lane::edgeValue`**:由下向上穿越 → 上升沿;
由上向下穿越 → 下降沿(**不要**再用信号幅值中点判定,那样拖动光标结果不会变)。
- 缓存键 `EdgeCountEntry` = 文件 + 信号 + `edgeValue` + `hasRange` + `rangeMin/rangeMax`;
拖横向光标、拖时间光标都会改变键并重算。缓存 `m_edgeCountCache` 是 **`mutable`**,
`EdgeCountsFor` 为 **const**(纯缓存,便于从 const 的 `EdgePanelWidth/EdgePanelRect` 调用)。
- 采样范围:时间单调时二分(左端多取一个采样点以便计入跨边界边沿),否则按区间过滤。
- **面板宽度自适应**:`EdgePanelWidth(laneIndex)` 用 `UiMeasureTextWidth`(**不含省略号**)
实测标题(`边沿计数` + `EdgeRangeSuffix()`)与每行“名称 / 上升沿 N 下降沿 M”的宽度,
取最大后再夹在绘图区内。绘制时**不要**再用 `UiEllipsize` —— 统计数字必须完整显示。
- **✕ = 关闭该网格的边沿计数功能**(`SetLaneEdgeCount(index,false)`),面板与横向光标一起消失。
- **双光标联动**:`EdgeRangeActive()`(DOUBLE 模式)+ `EdgeRangeMin/Max()`
(始终 `min/max(CursorTime(), CursorTimeAlt())`,因此左右交换后区间依然正确);
面板标题追加 `EdgeRangeSuffix()` = `(光标区间内)`。
## 校验脚本(.preflight,已 gitignore)
- `.preflight/edgeprobe2.ps1 -Column N`:全区间按阈值数组复算边沿数;
`.preflight/edgeprobe3.ps1 -T0 -T1`:指定时间区间复算。二者都与界面逐位一致。
⚠️ C# 内联代码里**不要写中文**(PowerShell 控制台编码会把列名变成乱码导致编译失败),
列索引用数字传入。
- `.preflight/cursor.ps1` 新增 `rclick:x,y` 动作(打开右键菜单)。
## PopupMenu 二级子菜单(cd6e0ed 新增)
- `MenuItemKind::SUBMENU` + `MenuItem::submenu`(`std::vector<MenuItem>` 自嵌套,C++17 合法)。
- 悬停父项 → `BuildSubLayout(index)` 展开;移开到其它条目 → `CloseSubmenu()`。
- 子菜单位置:`left = m_rect.X + m_rect.Width - kSubOverlap`,右侧空间不足则翻到左侧;
位置随 `RebuildLayout()` 一并重建。
- `ContainsPoint()` **必须**把 `m_subRect` 算进来,否则子菜单内点击会被判为"点菜单外" → 关菜单。
- `OnLButtonDown` 记录 `m_subPressedIndex`,`OnLButtonUp` 要求按下/抬起命中同一项才提交;
点击父项(SUBMENU)不下发命令,只保证展开。
- 子菜单内的 EDIT 项绘制走 `EditFieldRect(index)` 是**按主菜单矩形**算的(未支持子菜单 EDIT);
目前子菜单只有 NORMAL 项,不要往里放 EDIT。
- 键盘:Esc/← 先收起子菜单;→ 展开悬停项的子菜单;↑↓ 在子菜单内移动;Enter 提交。
## 实验性功能:边沿计数(cd6e0ed 新增)
- 开关与横向光标状态存在 `Lane`:`edgeCount / edgeValue / edgeValueValid / edgePanelOpen /
edgePanelX / edgePanelY`;`RebuildLanes` 用 `LaneCarry` 结构按信号继承(与 `fitYToSelection` 同一机制)。
菜单命令 `PLOT_CMD_LANE_EDGE_COUNT = 7`,入口 `PlotView::ToggleLaneEdgeCount()`。
- 绘制:`PaintEdgeCursor(g, lanes)`——横线 + 两端尖端朝内的三角把手(同 `CursorColor(1)`),
左端 Y 值胶囊(纵向居中于横线、靠网格左侧);**不画数据点取值标注**。
面板:标题栏 “GX” 徽标 + 边沿计数 + ✕;每个信号两行(名称用信号色 / 上升沿 N 下降沿 M)。
- 拖拽:`EDGE_MOVE`(IDC_SIZENS)、`EDGE_PANEL_MOVE`(IDC_SIZEALL),面板限制在 `m_plotRect` 内。
✕ 只收起面板(`edgePanelOpen=false`),横向光标保留。
- 边沿判定 = 穿越 `(minValue+maxValue)/2` 中点,NaN 视为断点;结果缓存在 `m_edgeCountCache`
(按文件/信号指针),随 `InvalidatePolylineCache()` 清空。
- ⚠️ **重大陷阱**:`PlotView::Paint` 有**两个**绘制叠加层的入口——
(a) `SceneCacheMatches() && BlitSceneCache(g)` 的贴图快路径(末尾直接 return)
(b) 整幅重绘路径。**任何随鼠标变化的新内容必须同时加在两边**,
否则只在缓存失效的那一帧可见(本次边沿计数就因此"点开关没反应")。
## 工具栏 toggle 状态(cd6e0ed 修复)
- `Toolbar::Layout()` 会 `m_buttons.clear()` 重建按钮;**必须**先用 `previousChecked`
按 action 继承 `checked`,否则「光标」文本变化触发的 `Relayout()` 会把同帧刚设置的
「Y 轴自适应」「选区放大」勾选态清空(用户报告的"切光标模式时前面开关被取消")。
## 网格图例编号(cd6e0ed 新增)
- `PaintLaneLegend` 在条目左侧先画 “GX” 徽标(`kLaneBadgeWidth=30 / kLaneBadgeHeight=17`,
与 SignalPanel 的 `kBadgeWidth/kBadgeHeight` 完全一致),随后 `cursorX` 后移
`kLaneBadgeGap=8`;图例底板从 `legendBand.X` 一直盖到最后一个条目。
## 增量差值显示(cd6e0ed 修改)
- 右侧光标取值文案:`值 (+0.0008)` / `值 (-0.134)`——**去掉逗号、用圆括号包裹**,
正负号按 `delta` 显式给出。
## 光标/提示(本轮新增约定)
- **信号悬停提示必须"跟随鼠标"**:`PaintSignalInteraction` 用 `m_mouseX/m_mouseY` 定位气泡
(左 = mouseX+12*scale、上 = mouseY+14*scale,再夹在绘图区内与该网格内)。
⚠️ **关键陷阱**:`OnMouseMove` 只在**状态变化**时返回 `changed=true`;仅鼠标位置变化时
返回 false → 宿主不重绘 → 气泡会**停在上一帧的位置**(用户报告的"提示不跟随鼠标")。
必须在 NONE 分支末尾补:
`if (m_hoverSignal.IsValid() && mouseMoved) { changed = true; }`
(`mouseMoved` 在 `OnMouseMove` 开头用与旧 `m_mouseX/m_mouseY` 比较得出)。
**`m_hoverSignal = hoverSignal;` 这一行一旦漏写,整个悬停提示都不会画**(会早退)。
`m_hoverAnchorX/m_hoverAnchorY` 已删除,勿恢复(曾经"锚定"是错的,用户要跟随)。
- **选区放大提示生命周期**(`SetHint` / `SetHintSticky` / `ReleaseHint`):
开启 → `横向或纵向拖动以放大选择区域,ESC退出选区模式`;左键按下**不显示**;
一旦开始拖动 → 每帧 `SetHintSticky(L"右键取消选区,ESC退出选区模式")`(sticky 期间 `HintNeedsAnimation()` 恒真,
宿主 `WM_TIMER` 保持,提示淡出被阻塞);松开 → `ReleaseHint()` 重新计时,提示淡出。
- **两个光标共用同一颜色/样式**(`theme.cursor` = Solarized 橙 `#CB4B16`;`theme.cursorAlt` **已删除**,勿恢复)。
- **工具栏动态文案**:`Toolbar::SetActionLabel(action,label)` 返回是否变化,`Application::Render()` 里
`if (m_toolbar.SetActionLabel(CURSOR, label)) Relayout();`(光标 / 光标x1 / 光标x2),
标签变化会改变按钮宽度 → 必须 `Relayout()`,按钮中心坐标也会随之漂移(约 +7px)。
- **取值胶囊避让**:`PaintCursor` 开头把 `m_cursorLabelRects` **预先填入**两条 `CursorLabelRect` 与
(双光标时)`CursorSpanRect`;取值标签逐条检测重叠,向下推最多 16 格(超出则向上、再夹取)。
不预填的话底部时间读数/Δt 标签会盖住取值。
- **时间读数标签叠放**:`CursorLabelRect(2)` 在双光标下若与 `CursorLabelRect(1)` 横向重叠,
自动上移一层(`first.Y - height - gap`,并夹在 `m_plotRect.Y + 2*scale` 以上)。
它同时被 `IsOnCursorTimeLabel` / `EditFieldRect` 复用 → 命中与编辑框位置自动一致。
- **增量差值只画在"右侧光标"**(`PaintCursor`):`rightCursor = (CursorTime() <= CursorTimeAlt()) ? 2 : 1`
(按时间比较,**不要**用 `m_activeCursor`),`showDelta = doubleMode && (which == rightCursor)`;
文案 `当前值 , +增量` / `-增量`(`delta = 本光标取值 − 另一条取值`,正值用 `+`,
**不要**再用 `±`)。拖拽把右光标拖到左边后,增量会自动改由新的右侧光标显示。
- **光标形态区分**(`QueryCursor`,本轮用户明确要求):
- 悬停可拖拽信号 → `IDC_HAND`(手掌);`MOVE_SIGNAL` 拖拽中 → `IDC_SIZEALL`(抓握,参考 Photoshop 平移)。
- **光标时间读数标签与 Δt 标签:悬停(未按下)不改变光标(返回 `nullptr` → 调用方回退箭头)**;
两者都叠在光标线正上方,因此**必须先于 `IsOnCursorLine` 判定并返回 nullptr**,否则会命中光标线给出 SIZEWE。
- `CURSOR_MOVE` / `CURSOR_SPAN_MOVE`(真正拖拽)→ `IDC_SIZEWE`(左右箭头)。
- 其他:`PAN` → SIZEALL,`SCROLL_LANES` → SIZENS,`RUBBER_SELECT` → 十字/方向箭头,
选区放大模式下**仅悬停(未按下)保持箭头**。`m_dragOnLabel` 已删除(形态不再依赖它)。
- **Δt 差值**:`CursorSpanRect` 文本 = `FormatSpanText()` = `L"Δt:" + FormatDurationCompound(s)`
(注意是**全角冒号**);宽容不下时落到主时间轴内(`m_timeAxisRect.Y + 2*scale`,高度被轴高夹取)。
`FormatDurationCompound` 规则:`>= 60` → `1h 1min 2s`;`1..60` → 整秒 + 可选 ms(`5.06 → 5s 60ms`,`5 → 5s`);
`< 1` → ms/us/ns/ps **只降一级**,但 **ps 保留小数**(`5.123 ps`)。
双击 Δt 编辑**始终以秒为单位**(`FormatDurationSeconds`,提交值直接就是秒)。
- **验证脚本补充**:`.preflight/cursor.ps1` 新增 `wait:ms` 与 `draghold:x,y,dx`(按下+移动,不松开);
名称表已含 `SIZEALL`。⚠️ 用它测大文件时必须先 `wait:13000` 等加载完,否则点击全部落空(曾误判为"光标不变")。
- `.preflight/act.ps1` 的截图输出参数是 **`-Out`**(不是 `shot:` 动作);`crop.ps1` 无 `-Scale` 参数,
且 `X+W <= 1725`、`Y+H <= 996`(越界会报"内存不足")。
- 实测 `VERY_BIG_DATA.CSV` 单信号(编码器A相)布局(125%、1725x996、客户区):
绘图区 `plotRect=(472,110,1224,796)`;密集方波使客户端 (700,300)/(900,200) 等处
`HitTestSignal` 均命中(距离 <0.3px,容差 7.5px);双光标时光标线 x ≈ 1081 / 1229(y 500 可抓),
时间读数标签 y ≈ 889..910(中心 x ≈ 1081 / 1229),Δt 标签中心 ≈ (1160, 934)。
## 8ae8d7a(中心轴曲线配色 + 筛选框 ✕ + 双光标拖拽性能)
- **中心平均值曲线配色改为 `HighContrastDerived(StyleColor(style))`**(匿名命名空间):
候选取 `MixColor(c,黑,0.72)` 与 `MixColor(c,白,0.72)`,评分 = 与信号色的距离² + 0.6×与绘图区底色
(`0xFFFDF6E3`)的距离²,取高分者;alpha 用 `baseAlpha = opacity*255`(与原信号完全一致)。
旧实现是「同色 + 0.55×alpha」→ 与波形难以区分。实测蓝信号 → `(11,39,59)`,红信号 → `(62,14,13)`,
改色后曲线立即跟随(像素级验证)。**光标标注该曲线取值时仍用 `StyleColor`(原信号色)**,勿改。
- **越界三角避让**:`MakeDataMarker(rawX, rawY, pinX, clipTop, clipBottom, topReserve=0)`,
`PaintCursor` 两个调用点传 `kCursorHandleReserve(9.0f)*m_scale`,
否则三角会盖住顶部把手(把手占 `[m_plotRect.Y, +7*scale]`)。
- **拖拽期间不画信号悬停提示**:`PaintSignalInteraction` 开头 `if (m_dragMode != NONE) return;`
(否则按下的上一帧 `m_hoverSignal` 残留 → 气泡"挂在原地"且每帧多 2ms)。
- **筛选框**:内容区四边统一内缩 `kFilterTextPad(4.0f)*m_scale`(`FilterFieldRect`,
旧版左右 7 / 上下 2 → 用户抱怨"左右空隙大");有内容时右侧画 ✕(`FilterClearRect()`,
边长 = 框高 - 2*pad,悬停 `danger` 底色 + `StyleHit::FILTER_CLEAR`,`SignalPanel::QueryCursor`
返回 `IDC_HAND`,`Application::WM_SETCURSOR` 增加面板分支);`ClearFilter()` 清空且保持编辑态;
列表空且筛选非空 → 居中提示「无匹配的筛选信号」。
⚠️ **陷阱**:✕ 落在 `FilterFieldRect()` 内,编辑态下 `Application` 会先把点击交给
`SignalPanel::OnEditMouseDown` → 必须在那个函数里**最先**判 ✕,否则点击只会挪插入符。
实测客户区:筛选框中心 ≈(110,181)、✕ 中心 ≈(428,181)(窗 1725x996、125%、默认布局)。
- **装饰区域脏标记快路径(本轮性能修复)**:`Application::m_chromeDirty` / `m_mouseOverPlot`
+ `InvalidatePlotRect()`。规则:
- `HandleMessage` 开头对**除 WM_PAINT / WM_MOUSEMOVE / WM_MOUSELEAVE / WM_SETCURSOR /
WM_NCHITTEST / WM_TIMER / WM_ERASEBKGND 之外**的消息置脏;
- `WM_MOUSEMOVE`:`plotOnlyMove = (m_activeTarget == Target::PLOT) || (inPlot && m_mouseOverPlot)`
时只 `InvalidatePlotRect`,否则整窗失效;
- `WM_MOUSELEAVE`:只有标签栏/工具栏/面板悬停变化才置脏,仅绘图区变化只失效绘图区;
- `WM_PAINT`:`GetUpdateRect` 必须**在 BeginPaint 前**取,更新区域完全落在绘图区内才允许快路径;
- `Relayout()` 与 `EnsureBackBuffer()` 重建时置脏;`Render` 里 `chromeDirty = m_chromeDirty ||
m_loading || 菜单/拾色器打开`,为假时跳过 bg/tabbar/toolbar/panel/splitter 且只 BitBlt 绘图区矩形。
- 实测 VERY_BIG_DATA 全信号双光标拖拽:单帧 **28ms → 12ms**(skip 帧),
平均(含每段拖拽的首末整帧)17ms。
- ⚠️ 用 `act.ps1` 测性能时 WM_MOUSELEAVE 每帧都来(它不移动真实光标),
漏掉这一条会让优化"看起来完全无效"(实测 full=30/skip=0)。
- **像素取色验证的坑**:鼠标(真实光标)悬停在稠密信号上时,`PaintHoverHighlight` 的
40% accent 光晕会整体压在整个包络带上 → 取色得到"信号色与 accent 的混色"
(红 0xDC322F 会被测成 `(142,88,116)`)。**验证颜色前先把鼠标移出绘图区**再截图。
- 复用脚本:`.preflight/perf_cursor.ps1`(双光标拖拽帧耗时)、`.preflight/scan_col.py` /
`scan_row.py` / `dark_colors.py` / `px.py`(Pillow 像素分析,pip 已装 pillow)。
## 实验性功能:均值滤波(本轮新增)
- `Lane` 新增 `meanFilter / meanFilterRadius(默认5) / meanFilterPanelOpen / meanFilterPanelX / meanFilterPanelY`;
**`LaneCarry`(RebuildLanes 的状态继承)与 `SplitLane` 都要同步携带这 5 个字段**,否则重建网格会丢状态。
- **算法唯一实现**:`Utilities` 的自由函数 `ComputeMovingAverage(values, radius, out)`(滑窗求和,O(n);
NaN 不参与平均;中心点为 NaN 或窗口无有效值时结果为 NaN)。
`PlotView::CenterCurveFor` 现在直接调用它(原先内联在 PlotView 里),因此
**预览曲线与「构建为新信号」的采样值逐位一致**。`PlotView::MeanFilterValueAt` = `CenterValueAt` 的转发。
- 曲线绘制:`PaintMeanFilterCurve` 直接转发 `PaintCenterCurve`(同算法、同 `HighContrastDerived` 派生配色、
同透明度)。`meanFilter/meanFilterRadius` 已加入 `SceneContentHash`(否则开关/改 N 不重绘)。
- 光标交互:`PaintCursor` 里把「中心轴」与「均值滤波」的取值胶囊抽成了局部 lambda
`paintDerivedCapsule(radius, suffix, meanFilter)`,两个功能共用;文案 `值(均值滤波)` /
`值(+增量 均值滤波)`(与中心轴完全同构)。
- **面板三态化**:`EdgePanelKind{COUNT, CENTER, MEAN}` 移到 **命名空间作用域**(PlotView.hpp,
不可放在类内私有区——匿名命名空间的 `kEdgePanelKinds` 数组需要访问它)。
`EdgePanelLayoutFor(laneIndex, kind)` / `EdgePanelWidth(laneIndex, kind)` 取代原来的 `bool centerAxis`;
`EdgePanelLayout` 新增 `button` 矩形。`m_edgePanelDragCenter` → `m_edgePanelDragKind`。
均值滤波面板:标题「均值滤波」+ 每个可见信号一行名称 + 「均值点数 N」编辑行 +
「构建为新信号」按钮行(高度 `kEdgePanelButtonHeight=30`)。面板高度按 `rowsPerSignal`(MEAN=1,其它=2)。
- `PlotEditTarget::MEAN_FILTER_RADIUS`:`EditFieldRect`/`EndEdit` 与 `EDGE_CENTER_SMOOTH` 同构
(夹在 0–1000000,清 `m_centerCurveCache` + `InvalidateSceneCache`)。
- 「构建为新信号」链路(跨层,注意分工):
1. `PlotView::OnLButtonDown` 点按钮 → 只登记 `m_pendingMeanFilterLane/Radius` + `SetHint`;
2. `Application::WM_LBUTTONDOWN` 在 `m_plot.OnLButtonDown` 返回 true 后调 `ApplyPendingMeanFilterBuild()`;
3. `Application::BuildMeanFilterSignal()` → `ComputeMovingAverage` → `DataSet::InsertDerivedSignal()`
→ 每个标签页 `TabConfig::RemapAfterSignalInsert()` → `ActiveTab().SetVisible(新引用, true)`
→ `ActiveTab().MoveLaneOfSignalBelow(新引用, 原引用)` → `SyncTabsToDataSet()`。
- **`DataSet::InsertDerivedSignal(fileIndex, afterSignalIndex, SignalData&&, int& newSignalIndex)`**:
在 `afterSignalIndex+1` 处插入;内部用 `ComputeStats` 补齐 min/max/finiteCount/粗粒度包络层,
按「全局信号序号」取调色板色(与普通新信号同一规则);并修正 `m_selected.signalIndex`。
- **`TabConfig::RemapAfterSignalInsert(fileIndex, insertPos)`**:可见性表插入一列(false)+ 网格内
同文件下标 ≥ insertPos 的引用全部 +1。凡是在 `DataFile::signals` 中间插入信号都必须调用它。
- **同一文件内必须按下标降序处理**(升序会把新插入的派生信号当成下一个原信号);
不同文件之间顺序无关。`ApplyPendingMeanFilterBuild` 里已按 (fileIndex 升序, signalIndex 降序) 排序。
- 命名规则:`<原信号 Title()> (N点均值)`(半角空格 + 半角括号,**不是**尖括号)。
- `InvalidatePolylineCache()` 现在还会清 `m_hoverSignal/m_hoverLegend/m_dragSignal`——
信号下标在插入后整体位移,残留的旧引用会指向错误的信号。
- `MoveLaneOfSignalBelow` 会把 `m_laneMode` 切成 `CUSTOM`(保持手动顺序,避免下次重建被 PER_SIGNAL 复位),
并调用新的 `TabConfig::RenameLanes()` 统一网格名。
- 实测(VERY_BIG_DATA、125%、1725x996):双光标下 G1 显示
`4.94199(均值滤波)` / `4.94214(+0.000145435 均值滤波)`;构建后列表出现
`编码器A相 (5点均值)`(紧跟原信号)、网格顺序为 原信号 / 派生信号 / 其它;
合并网格下点一次按钮会为网格内**每条**可见信号各建一条派生信号。
- 面板坐标(随信号行数变化):2 行时「构建为新信号」按钮客户区 ≈ (1589,287),1 行时 ≈ (1589,252);
✕ 客户区 ≈ (1659,146)。**每次测试前先截图量一次**。
- ⚠️ 用 act.ps1 测菜单时,`key:27`(Esc)在子菜单展开状态下**只会收起子菜单**,菜单仍打开,
之后的点击会先被菜单消费掉(会误判成"按钮无效")。
## 色域拖拽钳制 / 网格 GX 拖拽重排 / 均值滤波多阶(本轮)
### 调色器:把选点拖到控件之外会"弹回去"
- **根因(两处,都是"从 RGB 反推操作状态"导致的)**:
1. 色相 1 与 0 是同一个颜色(红)→ 拖到色相条最下面时 `FromHsv(1,…)` 得到红色,
下一帧 `ToHsv` 反推回来是 `h = 0` → **指示条弹回最上面**。
2. 拖到色域方块右下角之外时 `V = 0` → 颜色变黑 → `ToHsv(黑)` 的**饱和度无定义(=0)**
→ 选点弹回最左边("回到左下角")。
- **修法**:把"选择状态"与 RGB 分开保存 —— `m_sat / m_val / m_hue` 是权威值,
`ApplyGamut/ApplyHue` 只改它们再由 `FromHsv` 推出 RGB;
只有外部改色(RGB 滑块、十六进制输入)才用 `RefreshSelectionFromChannels()` 反向同步。
`CurrentHue()` 直接返回 `m_hue`。越界一律 `Clamp01` 投影到边界
("对鼠标离开区域后的位置做出响应")。
- 顺带统一圆角:色域方块 / 色相条 / 预览色块 / 色号框都用 `kSwatchCorner = 3.0f * scale`
(此前方块与色条是 `borderStrong` + 2px);RGB 轨道高度 6 → 8。
- 色相指示器改为"白描边圆角长方条、底色 = 该位置的颜色";色域选点去掉外圈深色描边。
- 色相条用 6 段 `LinearGradientBrush`(色相→RGB 本就是分段线性,6 段即精确)。
### 网格 GX 编号拖拽重排
- `TabConfig::MoveLane(from, target)`:target 是**插入位置**(0..n);向后拖先 `--destination`
再判断"是否真的移动",最后 `m_laneMode = CUSTOM` + `RenameLanes()`
→ 所有以 GX 位置推导的界面(信号面板徽标、图例徽标、右键菜单、浮动面板)自动同步。
- 拖拽:`PlotDragMode::LANE_REORDER` + `m_laneDragFrom/Target/MouseY/GrabOffset`。
`LaneBadgeRect()` 统一算徽标矩形(`PaintLaneLegend` 已改用,绘制与命中永远一致),
`LaneBadgeAtPoint()` 命中即拖;`QueryCursor` 在徽标上返回 `IDC_SIZEALL`。
判定顺序放在**浮动面板之后**(面板压在波形之上应优先)。
- **让位规则(容易写错)**:向下拖时让位的是 `(from, target)` 区间(**不含 target 本身**),
向上拖时是 `[target, from)`;写成 `<= target` 会多抬起一条网格。
目标槽位高亮 = `first + target*H - (target > from ? H : 0)`。
- **拖动性能关键**:不整幅重绘。静态场景缓存保持有效,`PaintLaneReorder` 把缓存里每条网格的
横向条带按位移 `BitBlt` 回去(几十微秒),被拖网格最后贴(压在最上层);
先把 `m_plotRect` 铺成绘图区底色让出空档,再画目标槽位高亮 + 被拖网格的强调轮廓与投影。
⚠️ 底色填充必须用 `m_plotRect` 而不是 `m_area`(`UiFillRect` 的 GDI 快路径不受 GDI+ 裁剪约束,
用 `m_area` 会盖掉时间轴)。
### 均值滤波升级:均值点数 X + 阶数 N(多阶)+ 逐信号条目
- 算法:新增 `ComputeMovingAverageIterated(values, radius, order, out)`(Utilities 自由函数),
第 k 阶以上一阶为输入;`order == 1` 与 `ComputeMovingAverage` 逐位一致(已断言)。
预览与「构建为新信号」共用它。
- 数据模型:`Lane::meanFilterRadius` → `std::vector<Lane::MeanFilterItem> meanFilterItems`
(`{ref, points(X), order(N)}`)+ `Find/Ensure/RemoveMeanFilterItem` / `MeanFilterEnabled`。
**`LaneCarry`(重建网格继承)、`SplitLane`、`RemapAfterSignalInsert`、
`RemapAfterSignalRemoval` 都必须同步处理 `meanFilterItems` 的引用**(否则会指错信号)。
启用时默认**只勾选网格内第一个可见信号**;面板高度随勾选数量动态变化。
- 面板:`EdgePanelLayout` 新增 `rowChecks/rowPoints/rowOrder/rowRefs`;
`EdgePanelWidth` 必须保证 `2*(max(labelX,labelN) + 6 + fieldWidth) + 8` 放得下
(否则"均值点数 X"的 X 会被数值框压住 —— 实测踩过)。
- 编辑目标:`MEAN_FILTER_RADIUS` → `MEAN_FILTER_POINTS` / `MEAN_FILTER_ORDER`,
用 `m_editMeanRef` 区分条目。
- 曲线缓存:新增 `m_meanCurveCache`(键 = 信号 + X + N),与中心轴的 `m_centerCurveCache` 分开。
`PaintCenterCurve` 拆成 `PaintDerivedCurve(g,resolved,lane,curve)` + 两个薄封装;
光标胶囊 `paintDerivedCapsule(points, order, 后缀, 是否均值)`(中心轴传 `(smooth,0)`)。
- `SceneContentHash` 必须 mix 每个条目的 ref/X/N。
### 工具经验
- ⚠️ 编辑工具在 CRLF 文件里插入的行是 **LF** → 会混合行尾;提交前统一成 CRLF
(本仓库 `core.autocrlf=true`,工作区 CRLF / blob LF 是正常状态,`git diff` 不会整文件重写)。
同样地,**曾带 BOM 的文件会被编辑工具重新写回 BOM**,提交前用 Python 去掉。
- `crop.ps1` 的 `X+W` / `Y+H` 必须严格小于图像宽高(1707x949),越界只报"内存不足"。
- 重定向 stdout 是 **UTF-16LE**,读日志要用 `utf-16-le`(否则会误判为"没有 FAIL")。
- 新增测试 `[T]` 色域拖拽钳制(含"下一帧不回弹")、`[U]` 网格拖拽重排+重编号+让位位移、
`[V]` 多阶算法等价性(1 阶 == 单次;2 阶 == 链式;总变差随阶数下降)+ 逐信号条目/默认勾选/面板几何。
合计 **196 项检查全部通过**。
- 最新一轮新增 `[W]`:`FormatByteSize` 单位自适应、`LoadCsvTable` 进度字节单调且总量恒定、
GX 徽标悬停不变光标/拖拽用 `IDC_SIZENS`、双光标读数小值在上、三读数同列对齐+三角、
中心轴默认勾选/X·N/三排布局/曲线与均值滤波同一缓存、横向光标读数让开把手与越界三角、
加载浮层渲染。合计 **241 项检查全部通过**。
- 测试脚本 `.preflight/uitest/build.ps1` 链接需要 `-lole32 -loleaut32 -luuid`(`CLSIDFromString`)。
## 派生曲线窗口缓存 + 浮动面板像素图层(本轮,性能重构)
场景:VERY_BIG_DATA(2580 万采样 × 5 信号)+ 5 网格全开「均值滤波 / 中心轴 / 弱波增强」,
拖动弱波增强滑块。**瓶颈不是波形**(关掉全部面板后拖拽仅 11ms),而是下面两处
(数据来自 `.preflight/perf4` 探针,任务结束前可删):
1. **派生曲线绝不能整条重算**:`PlotView::CurveSpanFor(kind, signal, points, iterations, level,
begin, end, spanBegin)` 只算「可见区间 ± 保护带」的窗口,用 `m_curveSpanCache`(deque,32 条)缓存。
- 对齐到粗网格(`grid = max(4096, 窗口/4)`)→ 轻微平移/缩放直接复用同一条窗口。
- 保护带默认 `clamp(窗口/8, 4096, 65536)`;**均值滤波必须 ≥ `(points+1)*iterations`**,
否则窗口边界处的取值与整条曲线不同(N 阶滤波向外传播 X*N 个采样)。
- ⚠️ **超大窗口(`> kCurveSpanCacheMaxSamples = 4M`)一律不进缓存**,改写进
`m_curveSpanScratch`:中心轴在"无光标区间"时要统计**整条信号**,窗口与信号等长
(千万级 ≈100MB),一旦被淘汰下一帧就重算整条曲线 → 实测 **1141ms 卡顿尖峰**,
5 条同时驻留还吃掉 **518MB**。改为一次性缓冲后尖峰消失、内存降到 2.6MB。
- `EdgeCenterCountsFor` **必须先查计数缓存、再取曲线**,且只请求「差集所需下标区间」:
面板每帧都调它,先取曲线就等于每帧取整条曲线(实测 30ms+/帧)。
2. **浮动面板必须做成像素图层**(15~27 个面板全画一遍实测 33~70ms,贴图 0.3ms):
`m_panelDc`(**位图原点必须与客户区一致** —— `UiText` 走 GDI,不能靠 GDI+ 变换平移坐标)
+ `m_panelSlots`(`PanelSlot{laneIndex, kind, key, x0,y0,x1,y1}`)。
- 键:`PanelGlobalKey()`(几何/缩放/主题/光标/区间/悬停/拖拽/编辑态)+ `PanelSlotKey()`
(面板自身:布局矩形、条目、勾选、参数、**仅 BOOST 才计**滑块数值、鼠标在面板内时计鼠标坐标)。
⚠️ 滑块数值若不加 `kind == BOOST` 限制,会连带把同网格的均值滤波/中心轴面板判脏,
再经下面的级联扩散到所有面板(实测 40ms/帧,修复后 14ms)。
- 脏了才重画,重画后把**后备缓冲区里该面板的像素**(含它下面的场景)拷进图层。
- ⚠️ 因此只要有一个面板重画,**所有"在它之后绘制且与它重叠"的面板也必须重画**
(`dirtyRects` 级联),否则后画者会把旧像素盖回去 —— 默认位置下各面板本来就叠在一起。
- `InvalidateSceneCache()` 与场景位图重建都要一并作废面板图层。
- 鼠标在面板内悬停 / 编辑态 / 跟随鼠标的叠加层(悬停光晕、图例高亮、拖拽虚影、提示动画)
一律让缓存失效,避免残影被缓存进面板像素。
- 已知取舍:面板主体 96% 不透明,缓存像素里"底下的场景"比对照帧旧一帧 → 透出约 4%
(实测面板内最大色差 29 级、仅在两个面板投影重叠的 46×2 像素区域),目视不可辨;测试容差取 32。
3. **验证**(`.preflight/uitest` 新增 `[AF]`,全套 374 项通过):
- "纯贴图帧" vs "整幅重绘帧"在绘图区**逐像素完全一致**(0 差异);
- 同一状态下"贴图" vs "只作废面板图层后重画"逐像素一致(面板矩形内 0 个 >32 的像素);
- 连续两次重画逐像素一致(排除渲染非确定性);
- `PaintEdgeCursor` 实测:全画 62~70ms → 贴图 0.3ms。
4. 实测汇总(perf4,5 网格全开实验性功能):滑块拖拽中位 **58.2ms → 23.6ms**(拖第 4 个网格 14.2ms);
平移交互帧 **61ms → 23ms**;平移松手后的全质量帧 168ms → 145ms;最大卡顿尖峰 **1141ms → 47ms**。
## 合并网格后新增信号 / 适应视图与自适应 / 面板排版 / 左右越界三角(上一轮)
### 合并网格后"显示全组/全部显示"把新信号挤进合并网格
- **根因**:只有 `TabConfig::SetVisible` 在 MERGED 模式下把模式切成 CUSTOM;
`SetFileVisible`(分组复选框)与 `SetAllVisible`("全部显示"按钮)没有,于是 `RebuildLanes(MERGED)`
把所有可见信号(含新增的)合并进同一网格。
- **修法**:两个入口同样在"确实有新信号由不可见变可见"时切到 CUSTOM
(用 `find(boolvec, false)` 判断,避免重复点击默默改掉划分策略)。
CUSTOM 分支会把未分配网格的可见信号 `push_back` 到 `lanes` 末尾 → 天然"排列在最下方"。
### 适应视图不再取消缩放模式与 Y 轴自适应
- `FitAll()` 原本 `Tab().ZoomMode() = NONE`(取消选区放大)+ `lane.autoRange = true`(全部网格)。
⚠️ 关键:`TabConfig::ComputeLaneRange` **只看 `autoRange`,不看 `fitYToSelection`**;
而 `fitYToSelection` 的效果是靠 `AutoRangeLaneToView` 写入 `autoRange=false + yMin/yMax` 实现的。
所以 `autoRange = true` 等于**把"选区 Y 轴自适应"的效果抹掉**(开关还在,但量程被打回整段幅值)。
- **修法**:保留 `ZoomMode()`;对 `fitYToSelection` 的网格改成重新 `AutoRangeLaneToView(index)`
(按新的整段时间视图重算),其余网格才 `autoRange = true`;提示文案按模式同步。
### 「按网格时间轴展开」其它网格不跟着自适应
- `FitTimeToLane` 只对本网格 `AutoRangeLaneToView`。时间视图是全局的,其余开启
`fitYToSelection` 的网格也必须重算 → 展开后补一次 `ApplySelectionAutoFit()`。
### 自定义颜色面板排版(上一轮遗留)
- **根因**:新增色域方块后 `kPanelWidth` 改成 324,但滑块行的轨道/数值/标签仍按旧的
相对位置摆放 → R/G/B 标签留在最左边(离自己的滑块 170px)、`m_hexRect` 右边界 338 > 面板 324
(色号编辑框画到面板外)。
- **新布局**:左列 = 色域方块(112) + 色相条(14),右列 = 三行"标签(16)+滑块+数值(40)"
在方块高度内**纵向居中**,下方一行 = 预览色块(52) + 色号(宽度 = 左列总宽 136)。
标签位置改为 `track.X - (kLabelWidth + kLabelGap)`,因此永远贴着各自的滑块。
面板高度 = padV*2 + 标题 + 方块 + 预览行 = 190(修复前底部有一条空白)。
- 色域选点:去掉外圈深色描边,只画"中心=当前色 + 白色描边"的圆。
- 色相指示器:黑色横条 → 白描边圆角长方条(宽 = 色相条 + 9px),底色 = 该位置的颜色
(`FromHsv(hue, 1, 1)`);色相条本身改用 6 段 `LinearGradientBrush`(色相→RGB 本就是分段线性,
6 段正好精确)。
### 左右越界三角画出网格 / 描边异常
- **根因**:光标贴边时 `pinX` 只离边界 1px,而三角沿朝向伸出 `kMarkerTriangleDepth(=7)`,
于是三角有一大半在网格绘制区之外,被裁剪后只剩残缺一角(看起来"描边坏了")。
- **修法**:`MakeDataMarker` 里对 offLeft/offRight 把锚点沿水平方向**向内**推
`depth + 1`(用常量 `kMarkerTriangleDepth`,与 `PaintDataMarker` 共用);
同时把"三角与取值标注纵向居中对齐"从仅上下越界扩展到四个方向。
### 工具经验
- ⚠️ **用户可能正在运行 Target.exe**:链接会报 `cannot open output file ... Permission denied`
(编译不受影响)。先用 `Get-Process` 判断;被占用时改为链接到临时名验证链接正确性,
并**绝不要 `make clean`**(会删掉用户正在使用的构建产物)。
- 再次提醒:编辑工具会把**曾经带 BOM** 的文件重新存成带 BOM(HEAD 里没有)→ 提交前用 Python 去掉,
并确认 `git diff -U0 | grep '^@@'` 里第一行没有 hunk。
- 测试断言别用整数鼠标坐标去踩浮点边界:轨道右端 287.5+105 取整成 392 → 比例 0.995 → R=254,
必须 `ceil` 或放宽断言(这不是产品缺陷)。
- 新增测试 `[N]` 合并+全选、`[O]` 适应视图保模式、`[P]` 网格时间轴展开联动、
`[R]` 左右越界三角完全落在绘图区内(用平移小位图渲染后把墨迹包围盒换回绝对坐标)、
`[S]` 调色面板所有控件都在面板矩形内 + 标签/数值与滑块同行。合计 **145 项检查全部通过**。
## 调色卡顿 / 色域方块 / 取值胶囊高对比配色 / 数据点越界三角(上一轮)
### 自定义颜色滑动卡顿(VERY_BIG_DATA,5 信号全选)
- **根因**:`SceneContentHash()` 把逐信号的 `color/opacity/lineWidth/mode` 都算进指纹 →
改一次颜色就整幅失效 → 全部网格的波形重光栅化。实测(5 网格 × 1 信号):
命中缓存 2.3ms、整幅重绘 **36~44ms**(每网格 `FillPath` 包络约 4~6ms);
无信号可见时 0.5ms(所以"没勾选信号时很流畅")。
- **修法**:`SceneContentHash(bool includeStyles)`;`SceneGeometryHash()` = `includeStyles=false`
(只有它一致 ⇒ 只变了外观)。新增 `SceneStyleEntry` 快照 + `FindStyleOnlyLanes(changedLanes)`
+ `RepaintSceneCachePatch(g, lanes)`:贴图 → 只重画受影响网格 → `BitBlt` 写回场景缓存 →
`Paint` 里 `SceneCacheMatches()` 与"仅样式变化"两个分支共用同一个 `paintOverlays` lambda。
实测 **36.7 → 11~12ms/步**(其余是 2ms 贴图 + 单网格 4~6ms + overlays)。
- ⚠️ **三个坑(都实测踩过)**:
1. `UiFillRect` 对大面积不透明矩形走 GDI `FillRect`,**不受 GDI+ 裁剪约束** → 补丁里的底色
必须按"补丁矩形"填,**绝不能**传 `m_area`/`m_plotRect`(否则整幅缓存被覆盖,实测差 40 万像素)。
2. 网格 rect 是**浮点数**,`floor/ceil` 会把补丁**向外**多算一行;那一行属于相邻网格
(例如它的分界线),于是边界上留下 1px 接缝(实测 y=209/y=309 各差 68/1224 像素)。
→ 补丁必须**向内**对齐到整数(上界 `ceil`、下界 `floor`),边界外的行一律由缓存提供。
3. `UiText` 是 GDI 文本,**只被自己的矩形裁剪、不被 GDI+ 裁剪区约束**;只要重画的网格内容
落在补丁之外(例如相邻网格的图例文字),就会被**重复绘制**在已贴图的缓存上
(ClearType 叠加产生可见差异)。→ 补丁外扩(bleed)是错的,补丁严格等于要重画的网格范围。
- **验证**:`[J]` 测试把"补丁帧"与"强制整幅重绘帧"逐像素比对(必须 0 差异),
并加一个对照帧(纯贴图 vs 整幅重绘,也应 0)说明比对基准可靠。
- 该优化对所有"只改外观"的路径都生效:调色板点击、线宽/不透明度滑块、绘制模式切换。
### 自定义颜色新增方块色域图
- `ColorPickerPopup` 新增 `m_gamutRect`(横轴饱和度、纵轴明度的**方块**)与 `m_hueRect`(色相条),
面板宽 214 → 324,高度改由内容最高者决定(`max(kGamutSize, 滑块列高)`,否则底部会留一条空白)。
- 色域方块**按当前色相预渲染到 `gd::Bitmap` 缓存**(`LockBits` + 直接写像素,140×140 ≈ 2 万点),
只有色相变化才重建;否则每帧要画上百条渐变。
- HSV 辅助函数 `ToHsv`/`FromHsv` 放在 ColorPickerPopup.cpp 匿名命名空间;**RGB 仍是唯一数据源**,
额外记忆 `m_hue`(消色差时色相无定义 → 沿用上次取值,这样"饱和度拖到 0 后还能靠色相条换回颜色")。
因此 ✓ 与 R/G/B 滑块、十六进制框、预览色块天然联动(都由 `m_channels` 推出)。
- 拖拽目标用 `DragTarget{NONE,SLIDER,GAMUT,HUE}`;光标:色域 → `IDC_CROSS`,色相条 → `IDC_SIZENS`。
- ⚠️ **持久化的 GDI+ 成员必须在 `GdiplusShutdown` 之前释放**,否则退出时 `~Bitmap` 访问违例
(实测退出码 `0xC0000005`)。做法:`Close()` 里 `m_gamutCache.reset()`,
且 `Application::Shutdown()` 在 `GdiplusShutdown` 前调用 `m_colorPicker.Close()`。
### 取值胶囊配色改为对信号配色线性响应
- `IsDarkColor` + `DrawLabelOutline(..., bool textDark)` 已被 **`UiContrastInk`** 取代
(放在 PaintUtil,可被测试直接调用;`DrawLabelOutline` 现在收**描边色**)。
- 方案:底色亮度 ≥ 0.5 用暗文字、否则用亮文字(这一步**必须是阈值**:任何连续的亮度取值都会在
中性灰附近同时远离两侧极值,对比度必然塌陷);随后把该侧极值色与**底色互补色**按 15% 线性混合。
互补色的相对亮度恰好是 `1 - 底色亮度`,所以文字/描边亮度是底色亮度的**线性函数**,
色相也随信号配色连续变化(不再是固定的黑/白两色)。
- 实测(256 级灰度 + 调色板色扫描):文字与底色亮度差**最小 0.293**,文字与描边亮度差最小 0.682,
文字色有 60 种不同取值(旧实现只有 2 种)。
### 数据点越界三角:判定依据 / 方向 / 居中
- `MakeDataMarker` 现在用**数据点原始坐标**与绘制边界比较得出 `offLeft/offRight/offUp/offDown`
(旧实现拿"夹取之后"的位置反推,于是**贴近边界但其实在视界内**的数据点也被画成三角)。
视界内保留真实纵坐标(只夹 2px 以防整体被裁),越界才贴边(上边界额外预留把手高度)。
- `PaintDataMarker`:三角**尖端朝外**(旧实现方向完全相反:上越界画下三角、左越界画右三角);
三角外接矩形在横纵两个方向都以锚点为中心;内三角改为**按质心等比收缩**(旧实现是"平移",
填充色会从尖端戳出来 → 描边不正确)。
- `PaintCursor` / `paintDerivedCapsule` 改为**先放好取值标注、再画标记**;上下越界时
`marker.y = 标注矩形中心`,满足"超过上下视界时三角要与显示的数值居中对齐"。
- 测试用"上/下/左/右四分之一区域的墨量比较"判定朝向(bbox 中心与锚点重合,不能用质心判方向)。
### 本轮测试与工具经验
- **永远不要用 PowerShell `Set-Content` 改源码**:`Get-Content -Raw` 按系统 ANSI(GBK)读 UTF-8,
写回时就变成乱码 + BOM,而且**不可逆**(反向 GBK 编码会因 U+20AC 等字符失败)→
只能 `git checkout` 后重做改动。改用编辑工具/Python 字节级操作。
- 编辑工具在"曾经带 BOM 的文件"上保存会**重新写回 BOM**(HEAD 里没有)→ 提交前用 Python 去掉,
并检查 `git diff -U0 | grep '^@@'` 里**没有第 1 行的 hunk**。
- 离屏 harness 追加:`setvbuf(stdout, nullptr, _IONBF, 0)`(崩溃也能看到断点)、
`CountDiff`/`DiffBounds`/`CountNotColor`、`MarkerShape`(bbox + 四边墨量)。
⚠️ 重定向后的 stdout 是 **UTF-16LE**(含 BOM),读日志要用 `utf-16-le`,否则会误判为"没有任何 FAIL"。
- 新增测试 `[J]` 样式局部重画一致性、`[K]` 色域方块与联动、`[L]` 对比度配色、`[M]` 数据点标记几何。
合计 **109 项检查全部通过**。
## 信号 ✕ 移位 / 所有 ✕ 改抬起响应 / 光标读数加 s / 图例悬停高亮 / 筛选框内圈(上一轮)
### 信号行的「移除」✕ 移到行尾,并统一样式
- `SignalRemoveRect` 现在 = `rect.X + rect.Width - kPadding*scale - kCloseButtonSize*scale`,
**与分组行 ✕ 完全同一列、同样尺寸**(`kCloseButtonSize = 18`),描边也统一成 `1.5f * scale`。
- `SignalBadgeRect`(GX 徽标)改为 `SignalRemoveRect(row).X - kRowTailGap*scale - width` →
徽标整体前移,与 ✕ 一起恒在列表最右侧;`kRemoveButtonSize(14)` 已删除,新增 `kRowTailGap(6)`。
- `PaintSignalRow` 里 ✕ **改到最后绘制**(压在最上层,与分组行顺序一致);名称右界在没有徽标时
用 `removeRect.X - 6*scale`(不再白留徽标宽度),`RowNameRect` 同步(注意它需要自己判 `badgeVisible`,
因为编辑框位置也走这个函数)。
- 125% 实测(面板宽 458):分组 ✕ = [420,442.5],信号 ✕ = [420,442.5](完全重合),GX = [375,412.5]。
### 所有 ✕ 改为「左键抬起」生效(按住移开再松开 = 放弃)
- 新增"待确认"状态:`SignalPanel::m_pendingClosePart/m_pendingCloseRow/m_pendingFilterClear`、
`PlotView::m_pendingPanelClose(+Lane/Kind)`;按下载入目标,抬起时**再次 HitTest 同一目标**才执行。
- ⚠️ **筛选框的清空 ✕ 有两条消息路径**:编辑态下按下先走 `OnEditMouseDown`(宿主把它归入
`Target::EDIT_FIELD`,抬起走 `OnEditMouseUp`);非编辑态才走 `OnLButtonDown/Up`(`Target::PANEL`)。
两条都要改,否则会出现"按下就把内容清掉"或"抬起却不清"。因此 `SignalPanel::OnEditMouseUp`
的签名改成了 `(int x, int y)`(Application 处唯一调用点已更新)。
- `TabBar::OnLButtonUp` 的 ✕ 原本只看"还在同一个标签页内",已收紧为"抬起点仍在 `closeRect` 内"。
- `PlotView::OnLButtonUp(int y)` → `(int x, int y)`(Application 三处调用点:菜单/拾色器抢占时传 (0,0))。
### 时间光标读数加 "s" 后缀
- 新增匿名命名空间函数 `FormatCursorTime(seconds) = FormatValue(seconds) + L"s"`;
**必须同时改 `CursorLabelRect`(算标签宽度)与 `PaintCursor`(画文字)**,否则文字会被裁掉。
- 内联编辑仍用不带单位的 `FormatValue`(编辑量纲始终是秒),未受影响。
- 验证技巧:把光标时间设成 `123456789.0`(`%.6g` → `"1.23457e+08"`)使文本宽度超过
`max(64*scale, ...)` 的 64pt 下限,才能通过 `CursorLabelRect().Width` 反推出"文本确实多了一个字符"。
### 悬停网格图例时高亮对应信号
- `PaintLegendHighlight` 里除了原来的图例条目半高亮,再对 `m_hoverLegend` 调用 `PaintSignalHalo`
(与"鼠标贴近曲线"同一光晕)。`AcquirePolyline` 有缓存,不必担心每帧重算。
- **测试要点**:悬停图例会同时给整个网格叠一层极淡的强调色(alpha 0x0A),做像素 diff 时
如果容差太小会把整片网格都算成"变化"。用 **容差 30**(淡色每通道只变化 ≤10,光晕 ≈80)
即可只留下光晕;再加"变化像素覆盖 > 40% 的列"作为"沿整条曲线"的结构性证据。
实测:34916 px / 1092 列(区域 1220 宽)。
### 筛选框编辑态不再画"内圈"
- 把 `UiDrawEditField` 拆成"底板+边框"与 `UiDrawEditFieldContent`(只画选区高亮/文本/插入符)。
- 信号面板的筛选框**外框已经自绘**(`m_filterRect` + 强调色边框),编辑态改用
`UiDrawEditFieldContent` 即可;几何仍传 `FilterFieldRect()`,插入符/拖拽选区行为完全不变。
- 判据:编辑态空内容时统计外框内缩 3px 区域里的强调色像素,修复前 ≈900(内圈边框),
修复后 11(只剩插入符)。带输入时的清空 ✕ 不受影响。
## 输入框溢出 / 标签页编辑态 / 信号 ✕ 删除(上一轮)
### 内联输入框全选高亮溢出(根因值得牢记)
- `UiFillRect` 有 **GDI 快路径**:`(argb & 0xFF000000) == 0xFF000000` 且 `area >= kGdiFillAreaThreshold (20000)` 时
改用 GDI `FillRect`,而 **GDI 文字/填充不受 GDI+ `SetClip` 约束** → 会画到裁剪区外。
- `UiDrawEditField` 的全选高亮正好是"不透明 + 长文本时面积巨大"→ 双击进入编辑(全选)时
高亮向左溢出输入框(组名 / 标签页名 / 信号名都会)。**修法**:调用 `UiFillRect` 前把
highlight 与 `clipRect` 手工求交(保留快路径,同时保证不越界)。
- 结论:**凡是用 `UiFillRect` 画"宽矩形"的地方,都不能假设 GDI+ 裁剪生效**,需要自己夹取。
### 标签页重命名在编辑态点击别处不退出
- `Application::WM_LBUTTONDOWN` 只在 `m_tabBar.HitTest(x,y)` 时把点击转给 `TabBar`,
点绘图区/面板/工具栏/分隔条时 `m_editIndex` 一直是 ≥0。
- 修法:在 `WM_LBUTTONDOWN` 里 `OnEditMouseDown` 之后加一段——`if (m_tabBar.IsEditing())`
就 `OnEditKey(VK_RETURN, result)` + `OnTabBarResult(result)`(**必须走 OnTabBarResult**,
否则新名字丢失)。
### 信号行 ✕ 删除按钮
- 新的 `RowPart::REMOVE_SIGNAL`;`SignalRemoveRect(row)` 的位置 = `rowRect.X + (kPadding + kDisclosureWidth) * m_scale`,
尺寸 `kRemoveButtonSize(14) * m_scale`,行内垂直居中 → **正好落在组复选框那一列**(与组复选框"垂直对齐"),
且在信号自身复选框(缩进 `kIndent`)左边,二者不重叠。125% 实测:X=[35,52],组复选框=[35,53.75],信号复选框=[52.5,71.25]。
- `HitRow` 里**先判 X 再判复选框**;`QueryCursor` 返回 `IDC_HAND`;hover 高亮用 `m_hoverPart == REMOVE_SIGNAL`。
⚠️ `QueryCursor` 因此从 `const` 改成非 const(内部调 `HitRow` → `EnsureRows`)。
- 删除协议:面板 `PostMessageW(m_host, WM_APP + 2, fileIndex, signalIndex)` →
`Application::RemoveSignal`。**最后一个信号 → 退化为 `RemoveFile(fileIndex)`**(不留空组)。
- 数据层:`DataSet::RemoveSignal`(move-shift + `pop_back` + `shrink_to_fit`,保证缓冲区内即刻析构)+
`DataSet::RemapSelectionAfterSignalRemoval` + `TabConfig::RemapAfterSignalRemoval`(删可见性列、
删网格内引用、同文件更大下标 -1、清空空网格)→ 再 `SyncTabsToDataSet()`。
与插入信号一样,**中间删除必须整体重映射所有标签页**,否则可见性/网格会错位。
### 离屏自动化验证(重要手法,可复用)
用户要求"测试期间不要干扰我的鼠标/键盘/屏幕",因此放弃 `act.ps1`/`real.ps1`
(它们会 `SetCursorPos` / `SetForegroundWindow` / 把窗口钉到 (0,0)),改用离屏 harness:
- `.preflight/uitest/main.cpp`:`app.Initialize(hInstance, SW_HIDE)`(窗口创建但从不显示)→
直接 `app.HandleMessage(app.m_hwnd, msg, wparam, lParam)` 注入输入 →
`app.Render()` 后用 `BitBlt` 从 `app.m_backDc` 拷到 `Gdiplus::Bitmap` 存 PNG。
**不显示窗口、不动鼠标、不抢焦点、不截屏。**
- 访问私有成员技巧:先 include 全部系统/标准头,再 `#define private public`/`#define protected public`,
再 include 项目头,最后 `#undef`。
- 编译:`g++ <项目 flags> .preflight/uitest/main.cpp <Build/Release/*.o 去掉 Main.o> -static -o uitest.exe
-lgdi32 -lgdiplus -lcomdlg32 -lpsapi -luser32 -lkernel32 -lole32 -loleaut32 -luuid`
(`CLSIDFromString` 需要 **ole32**;`GetProcessMemoryInfo` 需要 **psapi**)。
- 坑:`-fexec-charset=GBK` 下**窄字符串里的非 ASCII 会编译失败**(`Illegal byte sequence`)→ 输出纯 ASCII;
`Gdiplus::Bitmap` 无默认构造且不可赋值 → 用 `std::unique_ptr<Gdiplus::Bitmap>`;
`GetPixel/GetWidth` 非 const → 参数用 `Bitmap&`。
- **行的 rect / name rect 只有在真正 Render 过一次(布局跑完)之后才有效**,之前 `RowNameRect()` 返回 (0,0,0,0)。
- 双击组行(BODY)第一下会 `ToggleCollapsed`,**会把组折叠**(既有行为)→ 后续测试要么先展开,要么别用真实双击。
- 结果:31/31 通过;含"**删信号后样本缓冲从 123.8MB→111.5MB、进程私有字节 139→127MB**"这类内存释放断言,
以及"后面那条信号的可见性/网格被重映射到新下标、空出的下标不会被继承"。
- ⚠️ 离屏 harness 里注入的按下会走到宿主的 `SetCapture` 路径:始终用成对的按下/抬起(或 `InjectPress` +
`InjectRelease`),不要只按不放;隐藏且非前台的窗口通常拿不到真实捕获,加上立即释放,实测对用户无影响。
- 后续新增的测试辅助:`InjectPress/InjectRelease/InjectPressDragRelease`(验证"抬起才生效")、
`CountDiff`/`CountDiffColumns`(两帧像素差异与差异列数)、`CountNotColor`。
## 非固定周期采样下包络渲染丢失竖向跃变(Problem_Data.csv,本轮修复)
- **症状**:加载 `TestData/Problem_Data.csv`(1.28M 行、10 列、相邻采样间隔约 27µs **且不固定**,
含 4444 处 100–277µs 空档),勾选 `.UD\SLC_1ms\rtb_BodyIAC_K40+0x2`,
放大到主时间轴一大格 10ms 时波形出现大量"断开"。
- **关键前提(先确认走了哪条渲染路径)**:`BuildPolyline` 仅当 `visibleCount > budget`
(budget = plotWidth*2 = 2448)时走**像素列包络**路径,否则走**逐点描边**路径。
同一个"10ms 大格"区间内:跨度 68ms 时 samples≈2420 → 描边;跨度 74ms 时 samples≈2659 → 包络。
**测这类问题必须先确认实际分支**,否则会在描边路径上白折腾(第一轮就踩了这个坑)。
- **真正根因**:包络按像素列聚合,列内只统计"落在本列内的采样点"。而采样间隔(27µs)
与像素列宽(≈60µs)同量级,**一次跃变的两个端点会分别落到相邻两列**里 —— 对每一列来说
列内采样点都在同一边,于是这段竖向折线在两侧包络中都不出现 → 整段被丢掉,看起来就是断口。
实测 1224 列中有 32 列(与逐点描边对照)完全没有波形像素。
⚠️ 空列(`i1 <= i0`)只是次要原因(24 列);**"非空列丢失跨列折线"才是主因**。
- **修复**:列包络里补上「跨越列边界的两段折线在边界处的插值」——
新增自由函数 `PolylineValueAt(time, values, count, firstIndex, t, out)`,
取 `t0`(线段 i0-1→i0)与 `t1`(线段 i1-1→i1)处的取值并入 low/high。
折线在列内线性、极值必在采样点或列边界上,因此「列内采样点 + 两侧边界取值」= 该列包络的
**精确解**,相邻列边界取值相同 → 严丝合缝;同时顺带覆盖了空列(两侧边界取自同一线段)。
- **验证手法(推荐复用)**:
- 临时加 `JDI_FORCE_LINE=1` 强制走逐点描边路径,作为"正确波形"的对照基准;
- `.preflight/col_coverage.py <band.png> <line.png> X0 Y0 X1 Y1`:按像素列统计 `b > r`
(波形是紫蓝色,背景/网格都是暖色 r >= b)的覆盖率,报告"只被描边画出、包络漏画的列数"。
修复前 **32 列漏画**,修复后 **0 列**(1224/1224 全覆盖)。
- `.preflight/sim_band.py`:用 Python 复刻列包络算法并打印逐列数据,专治这类算法错误
(本次正是靠它发现"跃变两端分属相邻列")。
- 复现脚本:`.preflight/repro_problem.ps1 -ThirdDx <第三次框选宽度> [-ForceLine] [-NoInterp]`,
自动勾选第 4 条信号 → 选区放大三次 → 截图。
该文件面板行复选框客户区 y ≈ 250/283/316/**349**/382(第 4 行 = K40;点 382 会点到 K10)。
`ThirdDx=900` 得跨度 74ms(10ms/大格)且正好走包络路径。
- 回归:VERY_BIG_DATA(稠密包络)与 BIG_DATA(稀疏描边 + 包络)渲染均正常。
## 边沿计数:时间光标拖动卡顿修复
- **症状**:VERY_BIG_DATA 全选信号后拖动时间光标,未开实验性功能时很顺畅(≈13ms/帧),
一旦对某个网格开启「边沿计数」或「边沿计数(中心轴)」,拖动变卡(实测 24ms / 34ms 每帧)。
- **根因**:`EdgeCountsFor` / `EdgeCenterCountsFor` 的缓存键**包含时间区间**(rangeMin/rangeMax),
而拖光标时区间每帧都变 → 每帧全部命中失败 → 对每条可见信号重扫整段区间
(亿级采样下就是几十毫秒)。且缓存是 `std::vector` 且**无上限**,每个新区间都 push 一条,
拖一会儿就积累几百条,查找也退化成线性扫描。
- **修复**:缓存键改为「文件 + 信号 + 基准(阈值 edgeValue / 平滑半径 smooth)+ 是否限定区间」,
**区间不进键**;缓存项改为记录「上一次统计的采样下标区间 [begin, end) + 计数」:
区间完全一致 → 直接命中;与上一帧共享一个端点(拖光标时必然如此)→ 只扫描两端差集**增量加减**;
两端都变 / 首次 / 时间轴非单调 → 整段重算。缓存上限 `kEdgeCountCacheLimit = 48`。
- **关键正确性细节(已踩坑)**:`AccumulateEdgeCrossings(pairFirst, pairLast)` 统计采样对
`k ∈ [pairFirst, pairLast]`(每对为 `(k-1, k)`),**起点对的连续性必须由 `deltaAt(pairFirst-1)`
决定**(先把它当 previous 再进循环)。最初实现从循环内部把 hasPrevious 置 false 起算,
等于漏掉起点那一对,导致增量结果比整段重算**恒多 1 个上升沿**。
→ 这类增量算法必须用「与整段重算逐位比对」的临时探针验证(本轮做过,去掉探针前确认零 MISMATCH)。
- 实测(VERY_BIG_DATA、5 信号、双光标、网格 1 开功能、拖动时间光标):
中心轴边沿计数 **33.8ms → 13.3ms**;普通边沿计数 **24.5ms → 11.3ms**。
面板数值随拖动实时更新且与整段重算逐位一致;单光标(无区间)走全信号统计并被缓存。
- 注意:**横向光标(阈值)拖动**每次都会换基准 → 仍需整段重算,本轮未处理(用户未报告)。
## 验证脚本
- `.preflight/perf_edge.ps1 -Label X -CursorX <光标线客户区x> -SubY <子菜单项y>`:
自动勾选 5 信号 → 双光标 → 右键网格 1 → 实验性功能子菜单 → 开启指定功能 → 反复拖动光标,
拖动前后各截图(`su_X.png` / `PE_X_pre.png` / `PE_X.png`)便于核对面板数值变化。
- 该状态下**实验性功能子菜单项客户区 y**(1725x996、125%、5 信号、rclick(800,200)、hover(850,436)):
边沿计数 ≈ **429**、边沿计数(中心轴) ≈ **464**、均值滤波 ≈ **497**,子菜单 x ≈ **1105**
(父项行中心客户区 (841,436))。菜单位置随状态有 ±16px 漂移,**改状态后重新截图量取**。
双光标时光标线客户区 x ≈ **1080 / 1226**(y 500 可抓)。
## 命名审查与垃圾代码清理(上一轮,纯重命名/删除,无行为变更)
- **核心事实**:加载完成后全部采样都在内存里,程序此后与源文件再无关系。
因此凡名字暗示"仍在读写文件"的 API 都属于误导命名。
- 重命名:
- `SampleFileAt` → **`SampleSignalAt`**(取的是内存信号在给定时刻的采样值)。
- `DataSet::AddPreparedFile` → **`AddDataFile`**(只是把构建好的内存分组追加进集合)。
- `PlotView::InvalidatePolylineCache` → **`InvalidateDerivedCaches`**
(它同时清空折线/边沿统计/中心曲线/场景缓存与悬停引用,"Polyline" 名不副实)。
- `PlotView::CursorColor(int which)` → **`CursorColor()`**(两条光标同色,`which` 从未使用)。
- `UiEllipsize(g, text, font, max)` → **`UiEllipsize(text, font, max)`**
(纯测量,不绘制,`Graphics&` 参数被忽略 → 属于误导参数)。
- 删除 `UiTextWidth(g, text, font)`:与 `UiMeasureTextWidth(font, text)` 完全等价(重复 API)。统一用后者。
- **`(void)xxx;` 是本仓库的"垃圾信号"**,出现即代表参数是残留。本轮已全部清除:
- `TabConfig` 可见性族(`SetVisible/ToggleVisible/SetFileVisible/SetAllVisible/SoloSignal/SetMode/
SyncLanes/FileHasVisible/FileAllVisible`)去掉从未使用的 `const DataSet&` 参数。
仅 `SyncToDataSet` 真正需要 DataSet,保留。
- `PlotView::OnLButtonUp(int x, int y)` → `OnLButtonUp(int y)`;`OnMouseWheel(int x, int y, int d, bool s)`
→ `OnMouseWheel(int delta, bool shift)`;`PaintSignalHalo(g, ref, ...)` 去掉未用的 `ref`。
- `BuildDataFile` 里未使用的局部 `rowCount`。
- **删除的死函数**(声明+定义皆无人调用,均经编译验证):
`DataSet::Clear`、`DataSet::AddFromTable`、`SignalColorCount`、`WideToUtf8`、`StartsWith`、
`PlotView::ViewMinTime/ViewMaxTime`、`PlotView::EdgePanelRect/EdgePanelCloseRect/EdgeCenterPanelRect`、
`PlotView::ScaleLaneRange`、`PlotView::BeginLaneValueEdit`、`PlotView::LaneContentHeight`、
`SignalPanel::NeedsScrollBar/IsFilterEditing/FilterText/ContentHeight`、`PopupMenu::EditingItemId`、
`TabBar::IsDragging`、`TextEdit::PlaceCaret`。
- **删除的死字段**:`CsvTable::sourceRowCount`(与 `rowCount` 完全重复)、`CsvTable::skippedRowCount`
(只写不读)。`CsvTable::delimiter` / `fileBytes` 仍在用。
- **死代码审计脚本**:`.preflight/deadaudit2.py`(扫头文件声明 + 统计全仓 `名字(` 出现次数),
可快速复现"声明但无调用"的清单。注意 `调用形=2` 既可能是"声明+定义=死",也可能是
"头文件内联+1次调用=活",需人工确认。
- 验证(VERY_BIG_DATA):加载/勾选/筛选框(含"无匹配")/样式区线宽文字省略/选区放大(横向与纵向)/
双光标读数与 Δt/右键菜单(含 Y 轴数值与实验性功能子菜单)/合并与拆分网格/标签重命名省略
全部与改动前一致;`make clean; make all` 零警告。
- ⚠️ `act.ps1` 的动作参数是 **客户区坐标**(窗口钉在 (0,0)),而截图是整窗 → 换算 `客户区 = 截图 − (9,38)`。
标签栏在客户区 y≈28(截图 y≈66 已是工具栏「导入文件」按钮,误点会弹出文件对话框)。
---
## 本轮(2026-10-12,起源设备):图层架构缺陷修复 + 「弱峰增强」0~200%
### A. 渲染:移除后台图层线程(根因),改为逐网格同步滚动
- **用户现象**(`VERY_BIG_DATA` 全信号、选区放大后用中/左键拖动移动视界):**严重绘制错误**、**波形抖动**、**前台绘制被拖慢**。
- **根因(三个叠在一起)**:
1. **后台线程改写共享渲染状态**:`RenderLaneLayerRegion` 为了用宽窗口映射渲染,会临时改写
`m_area` / `m_plotRect` / `Tab().ViewMin()/ViewMax()` / 各网格矩形,完事再“恢复”。而 UI 线程的
`OnMouseMove`(平移写视界、命中测试读网格矩形)**无锁读/写**这些状态 → 后台的“恢复”会把
用户刚做的平移**弹回**、命中测试也会读到错位的网格。
2. **全局锚点 + 逐网格内容**:`RollWideBuffer` 用**一个** `m_layerAnchorMin` 给所有图层算
**同一个**位移,而各图层可能是在**不同视界**下渲染的(后台逐网格渲染、或拖拽中渲染)
→ 各网格被平移了**不同的真实距离** → 网格之间时间错位、贴回的像素互相矛盾。
3. **细条用错映射**:滚动补细条时用“当前视界”的映射渲染,而旧像素是另一个映射
(`layer.wideMin` 还被覆盖)→ ≤0.5px 接缝 + 拖动时的**抖动**。
- **修法**:
- **删除后台图层线程**(`m_layerWorker`/任务队列/`EnsureLayerWorker`/`StopLayerWorker`/
`LayerWorkerLoop`/`ScheduleStaleLayerRenders`/`m_layerBack`/`m_layerAnchorMin|Valid`/`m_lastPaintTick`/
`m_layerRescheduleStreak`)。**图层渲染一律在 UI 线程内同步完成**(不要再搬回后台)。
- **逐网格独立滚动**:每个图层以自己的 `wideMin` 为锚点算位移,细条用 `layer.wideMin` 渲染
(与已平移的旧像素同一映射)→ 消除接缝与抖动。
- `RenderLaneLayerRegion` 新增 `wideMin` 形参;新增 `LayerDefaultWideMin()`(当前视界居中)。
- `RollWideBuffer()` 逐网格:内容指纹对不上(`layer.key != LaneLayerKey(i)`,例如工具其他路径改了样式而图层还是旧的)
或锚点离视界太远 → 该网格**整块重画**;否则整数像素自贴图 + 补细条。
- `Paint` 宽底度分支:`if (!LayersReadyForView()) RebuildWideBuffer();` → 再 `RollWideBuffer()`
(跳度变化/首帧时**同步**重建全部图层,取代原来的后台补画)。
- `BackgroundLayerRenderCount()` 保留为公开诊断计数(现在统计同步图层区域渲染次数)。
- **离屏验证**(`.preflight/layercheck`,窗口 SW_HIDE、不动鼠标/键盘/屏幕):
- **居中图层 vs 整幅重绘:5 个网格逐像素 0 差异**(证明“底座管线”本身精确,与
f97c4cf 之前的结论一致)。
- 平移帧:逐网格最佳对齐位移 **≤1px**、**视界不被覆盖**、无网格被跳过;
残差为设计内 ≤0.5px 整数像素贴图残留(视界停止时的“结算帧”恢复像素精确)。
- 竞态/视界覆盖检测:24 次“跳度抖动 + 视界变化 + 渲染”→ **0 次覆盖**。
- 注:旧代码在该自动化场景下只能概率性复现缺陷(后台线程会被前台长期锁住而饥饿),
因此主要证据是“结构不可能出错(没有线程再改写渲染状态)+ 居中图层逐像素精确”。
- ⚠️ 见 §11.2 的硬约束(图层不得后台化、不得共用全局锚点)。
### B. 「弱波增强」→「弱峰增强」+ 等级 0~200%
- **改名**:界面文案与标识符全部改为 `WeakPeak`/`weakPeak`(`ComputeWeakPeakBoost`、
`SetLaneWeakPeak*`、`EdgePanelKind::PEAK`、`PlotDragMode::PEAK_LEVEL/PEAK_ITERATIONS`、
`PLOT_CMD_LANE_WEAK_PEAK`、`PeakExtremum` 等);导出信号名 `"<原信号> (弱峰增强 M% N次迭代)"`。
- **等级语义(0–2,面板 0%–200%)分段插值**(`ApplyWeakPeakStage`,`s` = 等级):
- 波峰:`s<=1` → `target = cur + s*(lower-cur)`;`s>1` → `target = lower + (s-1)*(upper-lower)`
(`lower/upper` = 左右同类极值中较小/较大者)。
- 波谷:`s<=1` → `target = cur + s*(upper-cur)`;`s>1` → `target = upper - (s-1)*(upper-lower)`。
- 语义:**0% 完全不动**(可做极小幅度调整)、**100% 到较温和的一侧**、**200% 完全拉平**;
两段在 100% 处连续。**新 1.5 == 旧 0.5**、**新 2.0 == 旧 1.0**(旧行为整体平移到后 100%)。
- `Lane::weakPeakLevel` 默认 **1.5**(= 旧默认 0.5 的效果);`levelPercent`(构建请求/缓存键)
范围 0–200、默认 150;常量 `kWeakPeakLevelMax = 2.0`、`kWeakPeakPercentMax = 200`。
- 滑块:轨道比例 = `level / 2`,数值显示 `level * 100`%。
- **测试同步**(`.preflight/uitest`):`[X]` 改为 0/50/100/150/200% 五个目标值;阶数扫描与
“完全恢复”块改用 150%/200% 以保持旧期望;`[Y]` 用 150%;派生信号名校验改用 `\u5f31\u5cf0...`。
- **⚠️ 本轮顺带修正两处“过期的测试期望”**(它们与实现早已不一致,只是没人跑过 uitest,
因为 f97c4cf 那轮只用 `perf.exe` 做验证):
- `[J]`:样式补丁**故意**渲染“快速帧”(关 AA)→ 该帧与全质量整幅重绘**不可能逐像素相同**。
现改为:断言补丁帧**标记为降级**,渲染到缓存重新有效(≤4 帧)后**用修复帧**与整幅重绘比对(0 差异)。
- `[AF]`:面板像素层 96% 不透明 → 底下波形的 ≤0.5px 残留会透出(实测最大 72 级、仅 6 个像素)
→ 容差由 32 提到 **96**(真正“面板内容过期”的差异是百级)。
- `[X]` 预览曲线像素计数的颜色容差 6 → **40**(真实信号上曲线多与波形重合,纯色像素本就少)。
- **验证**:`make release` 零警告;离屏测试套件 **380/380 通过**(exit=0)。
---
## 本轮(2026-10-12,起源设备):平移不再阻塞前台 + 弱峰增强逐信号参数 + 浮动面板层序
### A. 平移:前台刷新线程**永不阻塞**(用户猜测的评估与落地)
**用户的猜测与评估**:用户推测“网格自己的前后台缓冲区没准备好时,前台会等待它完成,于是退化到
未优化之前”—— **机制描述与当前代码不完全一致**(`f97c4cf` 的后台图层线程已在上一轮被删除,
因为后台改写共享渲染状态会覆盖用户的平移),但**问题类别与补救方向完全正确**:
平移时前台确实会因为“某个网格的图层没准备好(内容指纹过期 / 锚点离视界太远)”而**同步重画整块图层**
(旧代码里 `RollWideBuffer` 直接 `RenderLaneLayerRegion(..., 0, 0, defaultWideMin)`),
5 个网格就会在一帧里做完整批重画 → 卡顿;而“结算”帧在**拖拽期间**也会触发,
于是按住鼠标不动时在“宽基底快路径”与“整幅重绘(拖拽 → 关抗锯齿)”之间**每两帧振荡**,
表现为“波形质量轮流变差 / 断线 / 周期性卡顿,松手才恢复”。
**本轮改动**:
1. `RollWideBuffer()` **不再做任何整块重画**:逐网格整数像素平移 + 补细条;
内容过期 / 锚点过远(`|shift| >= bitmapWidth-4`)的网格只 `QueueLaneRebuild()`,
本帧仍按现有像素平移(位置照样跟随)。
2. 新增 `RenderPendingLaneRebuilds(budget)`:**每帧最多重画 `budget` 个网格**(`Paint` 传 1)。
队列非空时 `PostMessageW(kMessagePlotDerivedReady)` 请宿主再送一帧,直到补齐。
3. `BlitWideBuffer()` 改为**局部贴图**:图层只覆盖“视界 ± 半屏”,视界落在图层之外时
**只贴有像素的那一段**(`srcX/destOffset/blitWidth` 三个量把 BlitBlt 夹到可用区间),
其余留由底色填充 —— 即“未绘制的部分先暂时不管,下个循环就绪后再用新缓冲”。
旧行为是 `subX < -0.5 || subX + width > bitmapWidth + 0.5` 就 `continue` → **整个网格瞬间变空**。
4. **“结算”(整幅全质量精确帧)的触发条件收紧为三者同时成立**:
内容+视界与上一条快路径帧相同(`viewStopped`)**且** `m_dragMode == NONE` **且**
`LanesAllReadyForView()`。拖拽期间绝不结算 → 不再有质量振荡与周期性整幅重绘。
新增 `LaneLayerReady(i)` / `LanesAllReadyForView()`(结构 + 内容指纹 + 跨度全一致);
原 `LayersReadyForView()` 改名 `LayersStructurallyReady()`(只看结构,用于“跨度/布局变了
必须整批重建一次”这一种情形)。
5. 跨度 / 尺寸变化(缩放、布局变化、首帧)仍然走**同步整批** `RebuildWideBuffer()`:
这是“一次性”代价(不是平移路径),不做预算分摊,否则会出现整屏空白的中间帧。
**实测(离屏 `.preflight/panperf`,`VERY_BIG_DATA` 5 网格,深放大 ≈2ms/大格,每帧平移 60px)**:
- 平移 40 帧:**中位 6.2ms / p90 7.2ms / max 7.6ms**(旧实现会周期性出现数十~上百毫秒的尖峰)。
- **按住键且视界停在数据边界(视界不再变化)12 帧:中位 0.29ms / max 1.29ms**
—— 证明拖拽期间不再有整幅重绘(旧实现这里会每两帧穿插一次整幅重绘 + 关抗锯齿)。
### B. 弱峰增强:改为**逐信号参数**(对齐均值滤波)
- 数据层:`Lane::WeakPeakItem` 从 `using WeakPeakItem = MeanFilterItem`(只有 `ref` 有效)
改为**独立结构体** `{ ref, levelPercent = 150, iterations = 1 }`;
删除 `Lane::weakPeakLevel` / `Lane::weakPeakIterations`(网格级)。
- 因 `WeakPeakItem` 不再是 `MeanFilterItem`,`TabConfig::RemapAfterSignalRemoval()` 里原本
绑定到 `std::vector<Lane::MeanFilterItem>&` 的 `purgeRefs` lambda 改为**泛型 lambda + `std::decay_t`**。
- `PlotView`:删除 `LaneWeakPeakLevel/LaneWeakPeakIterations/SetLaneWeakPeakLevel/SetLaneWeakPeakIterations`、
`PlotDragMode::PEAK_LEVEL/PEAK_ITERATIONS`、`m_peakDragLane`、`EdgePanelLayout::slider/sliderIterations`、
`WeakPeakSliderHit()` 与三个滑块常量。
- 面板:弱峰增强**与均值滤波完全同构**(逐条目「名称 + 复选框」+ 第二排两个输入框
「增强等级 %」/「迭代次数 N」+ 底部「构建为新信号」);`EdgePanelLayout.itemHasParams`
统一了 MEAN/CENTER/PEAK 的参数行占位。新增 `PlotEditTarget::PEAK_LEVEL_PERCENT / PEAK_ITERATIONS`,
在 `EndEdit` 里分别夹到 0–200 / 1–10 并清 `m_weakPeakCurveCache`。
- 指纹与快照:`SceneContentHash` 混合每条 `WeakPeakItem` 的 `levelPercent + iterations`;
`CollectSceneDerived` 的 `flatten` 增加 `valueOf` 参数(MEAN/CENTER 取 `points`,PEAK 取 `levelPercent`);
`PanelSlotKey` 混合每条目的等级/迭代(面板数字随输入实时刷新)。
- 预览曲线 / 光标胶囊 / 「构建为新信号」全部改为**读取该信号自己的条目参数**,
因此「预览」与「构建」逐位一致,且同一网格内不同信号可用不同强度。
### C. 浮动面板:层序(z-order)与命中修复
**问题**(用户报告):两个面板重叠时,对**上层**面板的 ✕ / 标题栏操作会落到**下层**面板
(因为命中按固定的 `kEdgePanelKinds` 顺序遍历、先命中先 break),于是“上层 ✕ 关不掉 / 标题栏拖不动”。
**修法**:
- 新增层序 `m_panelOrder`(`std::vector<PanelRef{laneIndex, kind}>`,**队尾 = 最上层**,`mutable`)。
- `SyncPanelOrder()`:按现有顺序保留仍可见的面板,把**新出现**的面板**追加到队尾**(新面板永不被盖住)。
- `TopPanelAtPoint(x, y, lane, kind)`:**从队尾往前**找第一个命中的面板。
- `BringPanelToFront(lane, kind)`:点击面板(任意部位,含 ✕/标题/输入框)时把它移到队尾 = 置顶。
- 绘制循环改为**按 `m_panelOrder` 自下而上**绘制(与命中顺序、像素缓存级联一致);
`PanelGlobalKey()` 混合层序 → 层序变化时整层面板缓存作废并按新层序重画。
- `QueryCursor` 与 `OnMouseMove` 的“是否在面板上”判断也改用 `TopPanelAtPoint`(与点击一致)。
### D. 测试与脚本
- `uitest` 新增 `[AG]`:层预算(`RenderPendingLaneRebuilds(1)` 一帧只重画 1 个网格、剩余留在队列、
最终补齐且全部就绪)、**局部贴图**(把某层 `wideMin` 推到只覆盖视界左半 → 断言左侧被贴、
右侧保持哨兵色 = “未被绘制就不管”)、面板层序(上层面板 ✕ 命中上层、下层面板不受影响、
点击下层把它置顶、`TopPanelAtPoint` 随之改变、新开面板在最上层)。
- `uitest` 的 `[X]` 改为逐信号参数(含两个输入框的就地编辑:按下进入编辑、输入 175 回车写入
**该信号自己的条目**、999 被夹到 200、迭代次数写入、上下限夹取),并删除已不存在的滑块断言。
- 新增离屏探针 `.preflight/panperf`(平移帧耗时 + 按住键静止帧耗时)。
- **验证**:`make all` 零警告;`uitest` **388/388 通过**;`panperf` PASS;
`.preflight/layercheck` 保持“居中图层 vs 整幅重绘逐像素 0 差异 / 平移对齐 ≤1px / 0 次视界覆盖”。
---
## 本轮(2026-10-12,起源设备):弱峰增强改回**逐信号滑块** + 「迭代次数 N」上限可编辑
用户要求:把上一轮改成“逐信号两个输入框”的参数输入方式,改回**上一次提交之前**的输入方式
(滑块),但**每个信号各自一套滑块**;并且「迭代次数 N」滑块右侧的数值可以点击,
整行(滑块 + 数值)变成一个输入框来修改该滑块的**上限**。
### 数据模型
- `Lane::WeakPeakItem` 新增 `int maxIterations = 10;`(**该信号迭代次数滑块的上限**,用户可改)。
- 上限参与 `PanelSlotKey`(决定圆钮位置 → 面板必须重画),但**不进 `SceneContentHash`**
(上限不影响网格内画出的曲线,因此不该让波形重画)。
### 面板输入方式(滑块的“上一次提交之前”形态,但逐信号)
- `EdgePanelLayout` 新增四个**逐信号**矩形数组(`std::vector<gd::RectF>`,与 `rowRefs` 同序):
`rowLevelTracks` / `rowLevelValues`(增强等级滑块 + 右侧数值)、
`rowIterTracks` / `rowIterValues`(迭代次数滑块 + 右侧数值)。
PEAK 面板的 `rowPoints`/`rowIterations`(文本输入框)**恒为空矩形**(MEAN/CENTER 仍用它们)。
- 行高:每个**被勾选**的信号 = 名称行(22)+ **两行滑块**(各 26)= 74;未勾选只占名称行。
- 列宽算式集中在两个文件内辅助函数里,`EdgePanelWidth` 与 `EdgePanelLayoutFor` 共用,
避免两处漂移:`WeakPeakLabelColumnWidth(uiSmall)`、`WeakPeakValueColumnWidth(monoSmall)`
(后者取 `"200%"` 与 `"10000"` 的较宽者,为可改大的上限留位宽)。
轨道最小宽度用恢复的 `kWeakPeakTrackMinWidth = 120`。
- 恢复 `WeakPeakSliderHit()`(按轨道矩形上下各放宽 ≥8px 命中)与
`PlotDragMode::PEAK_LEVEL / PEAK_ITERATIONS`、`m_peakDragLane` + 新增 **`m_peakDragRef`**
(每个信号一套参数,拖动时必须记住是**哪个信号**)。
- 删除 `PlotEditTarget::PEAK_LEVEL_PERCENT` / `PEAK_ITERATIONS`
(等级只能拖滑块;迭代次数只能拖滑块),新增 **`PlotEditTarget::PEAK_ITERATIONS_MAX`**。
### 「迭代次数 N」上限的就地编辑(用户明确要求)
- **触发**:点击**迭代次数滑块右侧的数值文本**(`rowIterValues[i]`)。
- **输入框范围**:整条滑块行 —— 从轨道左端到数值文本右端
(`rowRect = {track.X, value.Y, value.X + value.Width - track.X, value.Height}`),
即“整个滑动条部分包括数字完整变为一个输入框”。左侧的「迭代次数 N」标签保留可见。
- **初始内容为空**:`BeginEdit(PEAK_ITERATIONS_MAX, lane, L"", rowRect)` 之后
再用 `m_edit.Begin(L"", false, EditFilter::BYTE, 0u)` 覆盖过滤器
(`BeginEdit` 默认 `NUMBER`,允许负号/小数点)。
- **灰色提示**:`PaintEditField()` 里当 `m_editTarget == PEAK_ITERATIONS_MAX && m_edit.Text().empty()`
时用 `UiTextAtClipped` 画当前上限(`m_peakEditCurrentMax`,进入编辑时记录、`EndEdit` 清零);
提示文本比插入符右移 `11 * scale`,避免插入符压在数字上。
该输入框在 `paintOverlays()` 里与 Y 轴上下限输入框一起绘制(在面板之上)。
- **提交**:回车 / 点击别处(沿用既有 `EndEdit` 路径)。规则:
* `EditFilter::BYTE` = **只允许数字**(负号、小数点、字母根本无法键入,粘贴同样被清洗);
* `parsedOk` 且 `1 ≤ value ≤ kWeakPeakIterationsMaxLimit(=1000)` → 生效为新上限;
否则(空内容 / `0` / 超出范围)→ **保持原上限不变**;
* 生效后若 `iterations > maxIterations` → **自动替换为新上限**(并清 `m_weakPeakCurveCache`)。
- **滑块量程**:`1 … maxIterations`;比例 = `(iterations-1)/(maxIterations-1)`(`max≤1` 时取 0)。
- 「构建为新信号」按该信号自己的量程夹取迭代次数。
### 验证(全部离屏,不占用鼠标/键盘/屏幕)
- `uitest` `[X]` 重写为:逐信号滑块存在性/层序/列宽/面板内、**等级滑块拖动**(中间=100%、
右端=200%)、**迭代滑块拖动**(左端=1、右端=当前上限 10)、
**点击数值进入 `PEAK_ITERATIONS_MAX` 编辑**(空内容 + 提示值 10 + 输入框横跨整行、
空内容回车不改、`-`/字母被拒、输入 40 生效、右端变为 40、下调到 4 时当前值被夹到 4、
输入 0 被拒、点击别处也能提交),并保存编辑态截图 `X_peak_max_edit.png` 供目视核对。
- ⚠️ 测试坐标坑:`UiPointInRect` 右边界为**开区间**,按压“轨道最右端”的取整坐标可能落在
命中区之外 → 测试里按 `lround(X+W) - 1`;而**拖拽**可以超出(`UiSliderRatioFromX` 会夹到 1)。
- `make all` 零警告;`uitest` **409/409 通过**;`panperf` PASS;
`layercheck` 保持“居中图层逐像素 0 差异 / 平移对齐 ≤1px / 0 次视界覆盖”。
---
## 本轮(2026-10-12,起源设备):斜向自由移动视界退化 + 预览曲线抗锯齿 + 预览曲线闪烁
用户报告(原文要点):以 `VERY_BIG_DATA.CSV` 为例,全信号显示 + **时间轴方向选区放大**(主时间轴约 **2ms 一大格**)后,
用**鼠标中键/左键斜向拖拽**自由移动视界**仍然卡顿**,且"这种不规则的视界自由移动会导致性能问题和绘制波形质量下降、错误绘制";
同时"实验性功能的预览曲线显示质量不如普通信号,似乎没有抗锯齿";"拖动弱峰增强滑块时预览曲线**消失一会儿又画出来**(跳帧闪烁)"。
### A. 斜向拖拽的根因(为什么"纯水平"测不出来)
`ShiftLaneRange(lane, dy)`(拖拽的纵向分量 → 网格幅值平移)每帧都会修改该网格的 `visual.yMin/yMax`,而:
1. `SceneGeometryHash()` 原本 = `SceneContentHash(false)`,**包含每个网格的 `yMin/yMax`** → 幅值一变,"场景几何变了",
于是**每一帧都判定宽基底不可用** → 退化到整幅重绘,并因拖拽置 `m_fastFrame = true` → **整幅 + 关抗锯齿**(质量下降 + 卡顿)。
2. `LaneLayerKey(laneIndex)` 的基也是**全局** `SceneContentHash(true, false)` → 幅值一变,**5 个网格的图层全部作废**并全部入队;
而 `RenderPendingLaneRebuilds(1)` **一帧只补 1 个** → 一直贴的是内容不一致的旧图层 = **错误绘制/断线**;
并且该调用点在 `BlitWideBuffer` **之后**,本帧补好的网格要到下一帧才显示("波形比鼠标慢半拍")。
⚠️ 这解释了自动化盲区:**纯水平平移**不会改幅值,所以旧探针"看起来一切正常";只有"斜向/不规则"拖拽才暴露。
### B. 修法(三个指纹各司其职)
- `SceneContentHash(bool includeStyles, bool includeDerived, bool includeLaneRange = true)`:
新增 `includeLaneRange`;`SceneGeometryHash()` = `SceneContentHash(false, true, **false**)`
→ **幅值范围不再参与"能不能走宽基底快路径"的判定**(它只影响"画到哪里",不影响"几何结构")。
- 新增 **`LaneLayerHash(laneIndex)`**:单网格图层的内容指纹 = 位图高度 + 该网格 `yMin/yMax` + 该网格各信号的
`visible/color/opacity/lineWidth/mode`;`LaneLayerKey(laneIndex)` 的基由全局内容指纹改为它。
→ 幅值变化**只让那一个网格**的图层失效(其余网格保持 Ready,零复制)。
- `Paint()` 的宽基底分支里,`RenderPendingLaneRebuilds(1)` 从 `BlitWideBuffer` **之后**移到 `RollWideBuffer()` **之后、贴图之前**
→ 本帧预算内补好的网格**本帧**就可见(消除"慢半拍/断线")。
### C. 预览曲线抗锯齿(用户:"似乎没有过抗锯齿机制")
- `PaintDerivedCurve` 原本 `if (m_fastFrame || spiky) SetSmoothingMode(None)`。
波形**基底**来自图层,**永远**是全质量抗锯齿;预览曲线却在任何交互期间变成无抗锯齿 → 叠加在一起就是"预览比普通信号粗糙"。
- 改为 **`if (spiky)`**:只有"陡峭段占比 > 1/8"的形状(方波/编码器)才放弃抗锯齿(那种形态下**观感几乎无差别**,
而实测代价极大:2448 顶点 26~37ms vs 无抗锯齿 3.5ms)。预览曲线只是叠加层、可见区间很小,抗锯齿代价可以承受。
- 交互期间**仍然**把顶点预算减半(`budget / 2`,即"低分辨率 → 松手恢复高分辨率"),只是不再关抗锯齿。
### D. 预览曲线闪烁(拖参数滑块时消失一会儿)
`AcquireNewestCurve(key, spanBegin)` 原本要求 **`entry->key == key`(参数完全相同)**。
拖滑块时参数每帧都在变 → 上一帧的曲线"键不同" → 找不到任何可用的旧曲线 → `CurveSpanFor` 返回 `nullptr` →
本帧**无曲线可画**(消失),直到后台按新参数算完并发布(间隔一会儿又出现)= 用户看到的闪烁。
修法:`AcquireNewestCurve(key, begin, end, spanBegin)` **只按 `key.signal` / `key.kind` 匹配**(参数无关),
并**优先挑「窗口覆盖所需区间 `[begin,end]`」的条目**(覆盖不到才退化为"最新的")。旧曲线只是参数略旧,
按采样下标映射位置仍然完全正确 → 视觉上平滑过渡而非闪烁。
### E. 离屏验证(不占用鼠标/键盘/屏幕)
- `uitest` 新增 `[AH]`(斜向拖拽 + 预览曲线连续性 + 抗锯齿):
① `LaneLayerKey` 逐网格失效(改 1 号网格幅值 → 1 号键变、2 号键**不变**);
② "同时平移 + 改幅值"的一帧 `m_wideLastValid == true`(走宽基底快路径)、未动的网格保持 Ready;
③ 参数连改 10 次:最差派生像素 21013(预热 22629),**空白帧 0 / 10**;
④ **隔离抗锯齿断言**:把 `PaintDerivedCurve` 画到两张 `gd::Bitmap` 上(`m_fastFrame = false` / `true`),
统计"既不是背景也不是纯线色的**混淆边缘像素**"→ 普通帧 8158/8420、快帧 8149/8404(都远大于 1/10)
—— 这正是回归护栏:**旧实现快帧的混淆像素只有 2 个**(断言立刻失败)。
⚠️ 写这类隔离测试的两个坑:GDI+ `Bitmap` **不保证初始为黑**(要 `Graphics::Clear`);测试曲线要用
**网格自己的值域**(`yMin..yMax`)且只画几个周期,否则曲线"每段都陡峭"会踩到那条无抗锯齿的逃生口,测出来的不是真问题。
- `panperf`(同一深放大区间,5 网格,每帧 60px 水平 / 斜向 60px+3px):
* 水平平移 40 帧:中位 **6.17ms** / p90 7.13 / max 9.22;
* **斜向拖拽 40 帧:中位 9.16ms / p90 10.67 / max 11.80,走宽基底快路径 40/40**(修复前该路径 0/40);
* 与整幅重绘的像素差:斜向最差 79809,水平基线 77310~80919 —— **同一量级**,即只有那套已知的 ≤0.5px 整数像素残差,
不存在"质量下降"或"错误绘制"。
- `layercheck`:`clobbers=0/24`、`pan frames wrong=0/4`、居中图层 vs 整幅重绘逐像素 0 差异(未回归)。
- `make clean && make all` 零警告;`uitest` **420/420 通过**。
### F. 教训(写进 §11.2 硬约束)
1. **网格幅值范围(`yMin/yMax`)不得进入"几何/内容"指纹**(那会让"平移的纵向分量"每帧毁掉快路径);
它只属于**该网格自己的图层指纹**。
2. **图层指纹必须逐网格**:任何"一个网格的变化使全部网格作废"的全局指纹都会把"一帧只补 1 个"的预算变成永久不一致。
3. **待重画网格要在贴图之前补**,否则用户看到的是"比鼠标慢半拍"的旧像素。
4. **异步发布的派生曲线必须有"参数无关"的兜底**(按信号 + 种类取最新),否则拖参数滑块必然闪烁。
5. **交互优化只能降分辨率(顶点数),不能关抗锯齿**:基底图层永远是全质量抗锯齿,叠加层一旦关了就会"看着更粗糙"。
---
## 本轮(2026-10-12,起源设备):上下/斜向平移卡顿(纵向预渲染)+ 深放大拖拽光标跑飞
用户报告:以 `VERY_BIG_DATA.CSV` 为例,**Y 轴自适应 + 选区放大**到主时间轴约 1~2ms 一大格后,中键拖拽移动视界时「左右很流畅,但上下或斜向会卡顿」,并猜测「上下移动时没有像左右那样的预渲染缓存」。另外「小缩放区间拖拽光标跑飞」的旧缺陷复现。
### A. 先验证猜测(量化,而不是凭感觉)
`.preflight/panperf` 新增:**纯竖直拖拽**(只改被拖网格幅值、视界不动)、逐帧统计 `BackgroundLayerRenderCount()` 增量,以及「5 网格全开 4 类悬浮面板(20 个)」后的水平/竖直对比。结果:
| 场景 | 水平拖拽 | 竖直拖拽 | 每次竖直帧的图层重画次数 |
| --- | --- | --- | --- |
| 无面板 | 6.29ms/帧 | 5.26ms/帧 | **1 次/帧**(水平:5 个横向补条/帧) |
| 20 个悬浮面板 | 37.7ms/帧 | **166.6ms/帧** | 同上 |
结论:猜测**部分成立**——竖直方向确实没有预渲染(每帧整块重画该网格图层),但**主因**是另一处:浮动面板的**像素图层全局键**里含 `visual.yMin/yMax`,平移的纵向分量每帧小改幅值 → 整层面板缓存失效 → 20 个面板每帧全部重画(≈130ms/帧)。水平平移不改幅值,所以只有竖直/斜向才触发 —— 这正好解释了用户观察到的「不对称」。
### B. 图层改为**二维预渲染**(横向 ±半屏 / 纵向 ±半个网格高)
- `LaneLayer` 新增 `marginY`(可见网格上方的余量行数)、`anchorYMax`(渲染时的 `yMax`)、 `pxPerValue`(像素/数值单位);位图高度 = `ceil(可见网格高) + 2*marginY`。
- 渲染时把该网格幅值范围按**像素行**精确扩展(上扩 `marginY/pxPerValue`,下扩
`(bitmapHeight - marginY - laneHeight)/pxPerValue`)。⚠️ 必须按行数反推数值扩展量:
只有这样「扩展跨度 / 画布行数」才严格等于前台的 px/数值,可见网格在画布中的映射才与整幅渲染**逐像素同源**。
早先按「上下各扩同样的数值量」时存在 <1px 的纵向缩放误差,水平平移的像素残差实测从 77k 涨到 105k,改对后回到 77k。
- 图层里的**幅值刻度必须以前台可见网格的跨度/高度为基准**(`m_wideTickSpan` / `m_wideTickHeight`):画布更高、范围更宽,若按画布值取 `NiceStep`,网格线密度就与前台不一致(贴图后拖动网格线跳变)。
步长一致 ⇒ 刻度落在同一批绝对值格点上 ⇒ 纵向平移就是纯像素平移。
- 贴图:`srcY = round(top - lane.Y) + marginY + round((anchorYMax - 当前 yMax) * pxPerValue)`;超出画布上下端时只贴**有像素的那一段**。`RollWideBuffer` 里只有 `|偏移|` 超出余量时才排队重画(重画即重新居中)。
- 图层指纹:`LaneLayerHash` 只计幅值**跨度**(`yMax - yMin`),**不计绝对范围**(绝对范围是贴图偏移的来源)。
- ⚠️ **网格分界线必须移出图层**:它锚定在网格的**屏幕位置**,而图层会被纵向偏移贴图;若烘进图层,竖直拖动时它会跟着波形跑。现在整幅重绘由 `PaintLane` 画,宽基底快路径由 `PaintLaneDividers()` 补画一次。
### C. 面板像素图层不再被幅值范围击穿
- `PanelGlobalKey()` 去掉 `visual.yMin/yMax`:面板内容(标题、条目、滑块、参数、边沿统计)都与幅值区间无关。
- 代价:面板底是不透明度 95.7% 的圆角矩形,缓存像素里"它下面的场景"可能旧一帧 → 透出 **4.3%**,色差**有上界**(≤ 0.043×255 ≈ 11 级),与既有的「旧一帧」取舍一致,[AF] 的容差 96 覆盖得住。
### D. 深放大下拖拽光标「跑飞」
- 历史:`388d3b6`/`67f659a` 里「视图变化后夹光标」被 `eaf5b9c` 刻意删除(改为「光标可滞留在视界外 + 贴边读数 + 方向三角」)。但深放大下一屏平移就是几十倍可视跨度,光标瞬间被甩到视界外很远 —— 用户看到的就是「拖拽(视界)时光标跑飞」。
- 修法(区分**连续手势**与**离散视图变化**):
* 新增 `ClampCursorsAfterPan()`,在**平移**路径调用:PAN 拖拽(中键/左键)、Shift+滚轮、方向键(`PanTimeByFraction`)。
* ⚠️ **离散**视图变化(选区放大 / 适应视图 / 按网格时间轴展开)**仍然不移动光标** —— 用户可以先量好再自由探索, 贴边读数可读、可拖回来。不要在这几处加夹取。
* 抓取**被钉在边缘**的光标读数时不带抓取偏移(`CursorOffViewSide` 为真 → `m_cursorGrabOffset = 0`): 否则首次移动会把目标送到视界外再被夹回边缘(表现为"一拖就跳很远 / 光标钉在边缘不动")。
### E. 验证(全部离屏,不占用鼠标/键盘/屏幕)
- `uitest` **432/432**:`[AH]` 更新为"幅值**平移**不再让图层失效(只改贴图偏移)、幅值**跨度**变化仍然失效",并新增「纯竖直拖拽 8 帧 **0 次图层重画**」;新增 `[AI]`:贴边读数抓取后光标落在鼠标处(误差 **0.0px**)、中键平移 1.5 屏期间 **0 帧出界**且光标确实跟手。
- `panperf`(VERY_BIG_DATA、5 网格、≈1~2ms 一大格):竖直拖拽中位 **3.8~5.0ms**、40 帧仅 **1** 次图层重画;水平 5.7~6.5ms(与修复前持平);对角 11~13ms(略增:补条要按 2 倍画布高渲染);整幅质量对比残差与修复前同量级(水平 77k~81k ↔ 修复后 77k~81k;对角仅被拖动的那一个网格增加,属同一类 ≤0.5px 整数像素残差)。
- 20 个悬浮面板时:竖直 **166.6 → 24.8ms/帧**,水平 37.7 → 38.8ms(持平)——"上下比左右卡"的不对称消失。
- `layercheck`:`clobbers=0/24`、`pan frames wrong=0/4`、居中图层 vs 整幅重绘逐像素 0 差异;新增探针 `.preflight/cursorcheck`(真文件 + 真缩放)**30/30**(单/双光标拖拽、贴边读数抓取、中键/键盘/滚轮平移)。
- `make clean && make all` 零警告。
---
## 本轮(2026-10-12,起源设备):纵向滚动补齐(消除绘制异常)+「弱峰调整」(-200%–+200%)
用户报告:① 上下拖动视界确实流畅了,但**绘制出现错误**——网格内的信号没有按预期移动,并出现抽搐帧;**尤其把中键按在最上方网格上、一路拖到窗口最下方(鼠标离开网格很远)**时最明显;左右拖动同样出现。② 把「弱峰增强」升级为「弱峰调整」:面板的「增强等级」改为「调整幅度」,量程 -200% – +200%,0 以上与原算法一致,0 以下为**削弱**(把弱峰压向相邻波谷、甚至抹除,左右平滑过渡;波谷反之)。
### A. 绘制异常/抽搐的根因(上一轮纵向余量只补了一半)
上一轮给图层加了「上下各半个网格高」的纵向余量,并把幅值平移改成**贴图纵向偏移**,但"偏移超出余量"时仍然走**排队整块重画**(`QueueLaneRebuild` → `RenderPendingLaneRebuilds(1)`,一帧只补一个网格)。于是:
1. 重画要到(最多)下一帧才生效,而 `BlitWideBuffer` 本帧就按新偏移取源行 → 源行超出画布时 **只能贴出有像素的那一段,其余行留在底色上 = 网格内出现空白条带**("绘制错误")。
2. 重画后的锚点与"贴图偏移"不同源 → 波形在"偏移画法"与"重画后的居中画法"之间跳 = "没按预期移动"。
3. 鼠标拖到网格外很远时,每帧的幅值位移都远超余量 → **每帧都触发整块重画**(画布比可见网格高 3 倍,代价不小)= 抽搐。
### B. 修法:与横向**完全同构**的纵向滚动
- `RenderLaneLayerRegion` 增加 `stripY/stripH`(纵向条带),因此可以只补画"新暴露的一整条"(整宽 × n 行)。
- `RollWideBuffer` 里新增纵向滚动:`BitBlt` 整块上/下移 `roll` 行 → 补画暴露的那一条 → `layer.anchorYMax -= roll / pxPerValue`(与横向 `layer.wideMin += shift / scale` 同一套记账)。
⚠️ **必须在横向补条之前**:横向补条会用"当前 `yMax`"重置图层锚点,顺序反了细条会带上错的纵向偏移。
⚠️ **滚动量量化**(`kLayerRollQuantum = 16` 像素):不足量子的偏移**不滚**,交给贴图的 `srcY` 偏移处理。 这样画布映射在拖动过程中保持稳定 → 折线缓存(键含幅值映射)命中;未量化时"每帧滚 1~30 行" 会让折线每帧重算(实测滚动 3~4ms + 整帧 10~14ms)。余量(上下各半个网格高)足以吸收 16px 的残差, 所以"不滚只偏移"绝不会贴出空白。
### C. 「弱峰调整」(原「弱峰增强」)
- 术语统一:`ComputeWeakPeakAdjust`、`WeakPeakItem.amountPercent`(-200–200,默认 150)、 面板标题「弱峰调整」、标签「调整幅度 %」、光标胶囊尾缀「弱峰调整」、 构建信号名 `<原信号> (弱峰调整 X% M次迭代)`(X 可为负)。
- 滑块:量程 -200% – +200%,**0 在正中间**(`ratio = (amount/200 + 1) / 2`);数值列宽度改为量 `"-200%"`。
- 目标值(`amount` = 幅度/100,识别规则**与旧实现完全相同**):
* `0 ≤ a ≤ 1`:当前值 → 同类极值中**较温和**的一侧(波峰取较低的波峰、波谷取较高的波谷);
* `1 < a ≤ 2`:该侧 → 另一侧(**与旧实现逐位一致**。⚠️ 波峰 `lower + (a-1)*(upper-lower)`、 波谷 `upper - (a-1)*(upper-lower)` 方向相反,符号不能写成一样——本轮先写错,被既有测试立刻抓住);
* `-1 ≤ a < 0`:当前值 → 相邻**异类**极值中**较近**的一个(波峰取较高的波谷、波谷取较低的波峰);
* `-2 ≤ a < -1`:较近的异类极值 → 较远的那个。即 -100% 拉到较近的波谷、-200% 拉到较远的波谷, 弱峰被压平甚至抹除,左右波形平滑过渡(波谷反向同理)。
* ⚠️ 负幅度**不得反向增强**:目标夹在"不越过当前值"的一侧(噪声使异类极值落在错侧时)。
- 平滑偏移(相邻极值之间的 smoothstep 过渡)**逐字未改**,因此负幅度下"一个极值被压低、相邻被抬高"时, 两者之间的**中间采样点可能越过中值线**——这是平滑过渡的正常结果, **不要把"逐点不得越过中值"写成断言**(只在极值点上断言"未反向增强")。
### D. 验证(全部离屏,不占用鼠标/键盘/屏幕)
- `uitest` **443/443**:`[X]` 正幅度 0/50/100/150/200% → 峰 550/555/560/580/600(**旧值逐位保持**)、 谷 450/445/440/420/400;新增负幅度 -50/-100/-200% → 峰 **500/450/400**、谷 **500/550/560**; 超范围(+7 / -7)夹取;"未反向增强"(极值点)与"健康峰不动"; 滑块:中间 = 0%、右端(拖出)= +200%、左端(拖出)= -200%;构建信号名含「弱峰调整」; `[AH]` 纯竖直拖拽 16 帧仅 **2** 次图层重画(量化滚动)。
- `panperf`:拖出网格 900px(鼠标远在窗口外)+ 对角平移 31 帧,**被拖网格的空白行数与整幅重绘逐帧一致** (64 vs 64 → 无空白条带)、帧时 10~13ms(滚动 2~4ms)、末帧与整幅重绘的差异与水平基线同量级 (best integer shift (-1,0),即已知的 ≤0.5px 整数像素残差)。
- `layercheck`:`clobbers=0/24`、`pan frames wrong=0/4`、居中图层 vs 整幅重绘逐像素 0 差异; `cursorcheck` 30/30;`make clean && make all` 零警告。
---
## 本轮(2026-10-12,起源设备):图层条带 GDI 裁剪(绘制错误真因)+「弱峰调整」定义与默认值
用户报告:① 上下拖动变流畅了,但**绘制错误仍在**——对某个网格中键上拖半个网格的纵向视界后,按住中键不放再左右/上下/斜向拖拽**都会**出现绘制错误;② 调整「弱峰调整」的判定定义与默认值。
### A. 绘制错误真因:图层条带渲染时 GDI 网格线"越界作画"并烘进图层
上一轮把图层条带渲染从"只有横向条"扩展到"横向条 + **纵向条**"(纵向滚动补画)。但 `PaintLane` 里 **GDI**(网格线 / 分界线)用的裁剪矩形是
`RectF(m_area.X, clipTop, m_area.Width, clipBottom - clipTop)` —— 它只把 x 限制在条带内,**y 仍是整个网格可见范围**。于是纵向条带(全宽 × 窄 y)渲染时:
1. GDI 网格线被画到**条带之外**(该处像素由滚动而来、本来是正确的);
2. 那些行原本是"波形盖在网格线之上",现在变成"网格线盖在波形之上" → 与正确渲染不一致;
3. 更糟的是这些错误像素**就在图层位图里**,会被后续每次平移带着一起移动 → 用户看到"拖到哪儿都有绘制错误"(错误行随内容漂移,无法通过重绘恢复)。
修法:把"本次允许改动的区域"算成 `laneClip = IntersectRect(m_area, 网格可见矩形)`,**同时**用于 `Graphics::SetClip` 与 GDI 的 `gridClip`。(横向补条本来就没问题,这也是为什么此前只有纵向/斜向才暴露。)
### B. 复现与验证(`.preflight/dragcheck`)
完全照用户手势:注入**真实中键消息**(`WM_MBUTTONDOWN` + `WM_MOUSEMOVE`),先把最上方网格向上拖半个网格高(触发纵向滚动),再保持按住拖左右/上下/斜向、最后拖出窗口。
判定方式(去掉亚像素噪声):把图层画布的**有效条带**(`marginY + vOffset` 起、网格高行)与"用同一 `wideMin` 新渲染的画布"逐像素比较 —— 滚动是纯整数平移,二者必须逐像素相同。(用 `Bitmap::FromHBITMAP` + `LockBits` 直接读像素,`GetPixel` 太慢。)
- 修复前:被拖网格出现**整行不一致**(2410 px ≈ 2 行 × 全宽),其他网格只有 ~20 px;
并且**错误行固定在 canvas 行 138**(即某条横向网格线的行号)——直接指向 GDI 网格线。
- 修复后:最差 ≤171 px(仅补条边界处的抗锯齿边缘,占 0.09%),0 处绘制错误。
### C. 「弱峰调整」的定义、默认幅度与符号显示
- **「是否偏弱」的新定义**(正/负幅度一致,用户明确要求):
* 波峰 k:必须**同时**低于
左侧「相邻第一个波峰 k-2 与相邻第一个波谷 k-1 的均值」**且**
右侧「相邻第一个波谷 k+1 与相邻第一个波峰 k+2 的均值」;
* 波谷 k:必须**同时**高于上述两个均值。
⚠️ 与旧实现(只与左右两个**同类**极值比较)不同:**整段均匀偏弱的纹波不再被判为弱峰**(它两侧没有"更健康"的组合可比),因此测试数据必须重设计(新的合成波形为分段线性、谷恒 500、弱峰 520、健康峰 620/640)。
- **默认幅度改为 0%**(面板打开即不改动波形;`WeakPeakItem`/构建请求/曲线缓存项三处默认值)。
- **滑块右侧数值始终显示符号**:`+150%` / `-150%` / `0%`(0 无符号);数值列按 `"+200%"` 取宽,避免文字跳动。离屏验证:`+150%` 与 `-150%` 的墨迹宽度一致(40 / 38 px),且都比 `0%`(16 px)宽 ≥8px。
### D. 验证(全部离屏,不占用鼠标/键盘/屏幕)
- `uitest` **441/441**:`[X]` 正幅度 0/50/100/150/200% → 峰 520/570/620/630/640、弱谷 580/540/500/500/500;负幅度 -50/-100/-200% → 峰 510/500/500、谷 590/600/620;超范围夹取、"未反向增强"(极值点)、健康段逐位不变、30% 幅度下 N=1/2/3/10 → 550/571/571/571(收敛)、默认 0%、滑块中间 = 0%、两端 = ±200%、数值带符号。
- `[Y]` 端到端:合成 ripple(谷恒 500、一个弱峰 520)弱峰区间 **20 → 70**(健康 120)。
- `dragcheck` PASS(0 绘制错误);`panperf` 竖直中位 5.1ms、水平 7.1ms、面板全开竖直 25.0ms / 水平 41.3ms;`layercheck` `clobbers=0/24`、`pan frames wrong=0/4`;`cursorcheck` 30/30;`make clean && make all` 零警告。
---
## 本轮(2026-10-12,起源设备):「弱峰调整」三组识别规则 + 滑动条 ±400% 梯度驱动
用户要求:滑动条量程扩到 **±400%**,并按"梯度"同时驱动**三组识别规则不同的点**;每组的 ±200% 映射里,±200% 的落点由"较极端的那一个邻居"改成"两个邻居的均值"。
### A. 三组识别规则(以波峰 k 为例;`L`/`l` = 左相邻波峰/波谷,`r`/`R` = 右相邻波谷/波峰)
令 `leftMean = (L+l)/2`、`rightMean = (r+R)/2`:
| 组 | 规则 | 一句话 |
| --- | --- | --- |
| 第一类 | `v < leftMean` **且** `v < rightMean` | 两侧的"相邻峰+相邻谷"均值都要低于 |
| 第二类 | `v < leftMean` **或** `v < rightMean` | 任一侧满足即可(从"且"放宽为"或")|
| 第三类 | `v < L` **且** `v < R` | 只与左右**同类**极值比较(最宽松)|
波谷把"低于"换成"高于"、min/max 互换。⚠️ 三组**不是包含关系**:`v < min(L,R)` 并不蕴含 `v < max(leftMean,rightMean)`(因为 `leftMean < L`、`rightMean < R`),所以确实存在"只满足第三类"的点。
### B. 梯度驱动(滑动条 |a| ∈ [0, 400%])
| 滑动条 | 第一类 | 第二类 | 第三类 |
| --- | --- | --- | --- |
| 0 – 100% | 0 – 100% | — | — |
| 100 – 200% | 100 – 200% | 0 – 100% | — |
| 200 – 300% | 200%(上限) | 100 – 200% | 0 – 100% |
| 300 – 400% | 200%(上限) | 200%(上限) | 100 – 200% |
实现(`a` = 百分比/100):`first = sign*min(|a|,2)`、`second = sign*clamp(|a|-1,0,2)`、`third = sign*clamp(|a|-2,0,2)`。某个极值同时满足多组时取**幅度绝对值最大**的一组(= 最严格的那组)。
### C. 每一组自身的目标值映射(±200%)
- `0 – +100%`:当前值 → 左右**同类**极值中**较温和**的一侧(波峰取较低者、波谷取较高者);
- `+100 – +200%`:该侧 → 左右同类极值的**均值**(原先取"较大的那个");
- `0 – -100%`:当前值 → 相邻**异类**极值中**较近**的一个(波峰取较高的波谷、波谷取较低的波峰);
- `-100 – -200%`:该侧 → 相邻异类极值的**均值**(原先取"最小的那个")。
⚠️ 削弱的"不得反向增强"夹取保持不变;⚠️ 平滑偏移(相邻极值之间的 smoothstep)**逐字未改**,因此负幅度下相邻极值一压一抬时,两者之间的**中间采样点可能越过中值线**(正常现象,只能在**极值点**上断言"未反向增强")。
### D. 界面
滑动条量程 → **-400% – +400%**(0 在正中间,`kWeakPeakSliderScale = 4.0`);数值列按 `"+400%"` 取宽;右侧数值**始终带符号**(`+400%` / `-400%` / `0%`)。
### E. 验证(全部离屏,不占用鼠标/键盘/屏幕)
- `uitest` **463/463**:
* 第一类点(弱峰 520、相邻峰 620/640):0/50/100/150/200% → 520/570/620/**625/630**;
弱谷 580(相邻谷 500/500、相邻峰 620/600):580/540/500/500/500,负值 590/600/**605/610**。
* **只满足第二类**的峰 130(相邻峰 200/150、相邻谷 100/90 → 侧均值 150/120):
100% 不动;150/200/250/300/400% → 140/150/162.5/175/175;
负值 -100% 不动;-150/-200/-250/-300% → 115/100/97.5/95。
* **只满足第三类**的峰 195(两个 200 峰之间,侧均值为 150):200% 不动;250/300/400% → 197.5/200/200。
* 第一类点在 200% 已达自身上限(400% 不再变化);±400% 夹取;默认 0%;滑块中间 = 0%、两端 = ±400%、
轨道 3/4 处 ≈ +200%(第一类上限)。
- `[Y]` 端到端:合成 ripple(谷恒 500、一个弱峰 520)在 50% 幅度下弱峰区间 20 → 70(健康 120)。
- `panperf` PASS、`layercheck` `clobbers=0/24`+`pan frames wrong=0/4`、`cursorcheck` 30/30、 `dragcheck` 0 绘制错误;`make clean && make all` 零警告。
---
## 本轮(2026-10-12,起源设备):长距离中键拖拽的绘制错误/抽搐("距离"不再退化成排队重画)
用户报告(并附 `TestData/PROBLEM_IMG/*.png`):按住中键把视界**纵向移动好几个网格高**、不松开再挪回来(或继续上下移动)时,出现**大量绘制错误与抽搐**;要求"验证时不要松开中键截图"。
### A. 根因(`RollWideBuffer` 的"距离太远 → 排队"分支)
上一轮的图层余量只覆盖「横向 ±半屏、纵向 ±半个网格高」,超出余量时旧代码只做:
QueueLaneRebuild(laneIndex); // 本帧不画
continue;
于是只要"位移超过余量":
1. 该帧 `BlitWideBuffer` 的 `srcY = marginY + vOffset` 已经落在画布之外 → 只能贴出/贴不到像素,
网格里出现**空白条带**(用户截图里的"白色块");
2. 下一帧位移依旧超限 → **每帧都重新入队**,而 `RenderPendingLaneRebuilds(1)` **一帧只补 1 个网格**,
5 个网格永远补不完 → **持续抽搐**;
3. 纵向余量只有 ±`marginY`(半个网格高 ≈ 80px),而中键纵向拖拽每帧很容易移动 20~150px,
**几乎必现**;横向同理(余量 ±半屏,快速拖拽超过它)。
### B. 修法:距离类问题一律本帧就地解决
- **只有"内容真的变了"(`contentStale`:外观 / 网格几何 / 幅值跨度 / 可见性…)才排队** —— 那时画面
本身仍是"旧内容",不缺口,可以让后续帧慢慢补。
- 距离类问题本帧处理:
* `|shift| < bitmapWidth` 且 `|vOffset| < 画布高` → **滚动**:`BitBlt` 整块平移 + 只补画暴露的细条
(横向条 / 纵向条),并同步锚点记账(`wideMin` / `anchorYMax`);
* 任一超出画布(内容整体滚出)→ **本帧立刻整块重画并重新居中**
(`RenderLaneLayerRegion(i, layer, 0, bitmapWidth, 0, 0, LayerDefaultWideMin())`,实测 0.4ms/网格)。
### C. 探针(`.preflight/dragcheck`,按用户要求"全程按住中键")
真文件中键消息注入,**不松开**;每一步都做三类检查:
1. **图层条带 vs 同锚点新渲染**:逐像素(滚动是纯整数平移,必须相同);
2. **覆盖率**:`(ViewMin - layer.wideMin) * scale == marginX`(±1px)且
`marginY + vOffset` 起的**一整个网格高**都落在画布内 —— 只要有一条不成立,
`BlitWideBuffer` 就会留下空白条带;
3. **画面结构**:网格内"无墨迹行数"与"墨迹占比"对比**整幅重绘参考帧**
(空白条带 / 内容错位探测器;不松开中键时截取)。
场景:① 上拖半个网格高;② 按住拖左右/上下/斜向并拖出窗口;③(用户复现)**纵向拖 4 个网格高再拖回来**、
再小幅上下摆动;④ **快速大位移对角拖拽**(每帧 150×110px,超过纵向余量)。
**A/B 验证探针有效性**:把纵向滚动临时关掉(`kLayerRollQuantum = 100000`)→ 立刻报
`worst layer band diff 76221`、`drawing errors 11`;恢复后 **0 错误**。
### D. 两点说明(避免误判)
- `dragcheck` 的"画面像素 diff"会稳定出现 ~2.5 万像素(一个网格的 ~11%)差异:这是**图层与整幅重绘的
列相位差**(图层画布 2449px 按 1px 列聚合,整幅重绘 1224px 列,相位差 ≤0.5px),对**稠密脉冲信号**
(256kHz 编码器)会放大成大片边缘差异;这是长期存在、由"结算帧"收敛的现象,**不是**本轮缺陷。
因此画面检查采用结构化指标(空白行数 / 墨迹占比)而不是像素 diff。
- 用户截图里"整条网格被信号色填满"是**正确**渲染:把幅值窗口平移到与稠密信号相交的区间时,
每列的 min/max 包络本来就覆盖整个网格(示波器上同样如此)。
### E. 验证(全部离屏,不占用鼠标/键盘/屏幕)
- `dragcheck` PASS(0 绘制错误;长拖拽 80 帧 + 快速对角 24 帧,最差帧 ~25ms);
- `uitest` **463/463**;`panperf` PASS(竖直中位 4.4ms、水平 7-10ms);
`layercheck` `clobbers=0/24` + `pan frames wrong=0/4`;`cursorcheck` 30/30;
- `make clean && make all` 零警告,`Build/Release/Target.exe` 已更新。
---
## 本轮(2026-10-18):悬停光晕 210ms/帧 → 4ms(探针 `panflash` / 新增 `stress`)
用户报告三件事(VERY_BIG_DATA 与 MF4 都要测):① 快速中键拖拽时**所有网格的信号一起消失**;
② 组折叠/展开动画在鼠标移到"即将出现的信号行"上方时**卡顿甚至中断**;③ 悬停巨型信号(编码器A相)时
**整帧卡死、浮动提示不再跟随鼠标**。
### A. ③ 的真凶:`PaintSignalHalo` 用**加粗 GDI+ 折线**描边整条曲线
`panflash` 探针复现(`hoverMean = 210.31ms` vs `baseline = 0.90ms`)。根因是"像素列包络带"信号的
`points` 是**每列上/下包络交替的满幅竖锯齿**,GDI+ 描边代价 ≈ 线段数 × 覆盖行数(2503 顶点 × 1081 行)。
修法(`PlotViewPaint.cpp`):
1. **包络带信号(`cached.bandMode`)**:光晕改为 `FillPath` 填充**与曲线本身同一几何**的包络带
(`bandBreaks` 段 → 上包络正向 + 下包络反向),alpha 0x55;
2. **只画鼠标附近一段**(`kHaloWindowHalfWidth = 72` 逻辑像素、`kHaloWindowMinPoints = 360`):
- 包络带:把窗口换算成**列区间**再填充(下包络反向存储 ⇒ 列 c 在 `start+half+(half-1-c)`,
列区间对应一段**正向可读**的连续区间);
- 折线描边:O(n) 扫一遍取窗口内的**连续顶点段**,段首尾各多含一个顶点(跨窗口的线段完整画出)。
3. 窗口中心 = 鼠标 x(鼠标不在绘图区时取绘图区中心,保证列表悬停代价同样恒定)。
结果:**210ms → 4.0ms**(`baseline 0.9ms`),提示恢复跟手。几何正确性由"透明位图上两次绘制"探针确认:
窗口内的像素与整条光晕**逐像素相同**、窗口外 0 像素、且全部落在包络带内(`outsideBand=0`)。
### B. ② 是 ③ 的连带症状
列表悬停 → `Application::Render` 把 `panel.HoveredSignal()` 推给 `SetExternalHoverSignal`
→ 每帧(鼠标动一次就一次)重画光晕。以前悬停在 2500 万采样的编码器行上 = **210ms/帧**,
于是动画看着"卡顿甚至中断"(定时器被饿死,动画帧被拉长后跳变)。
同一修复后 `[3b] listHover mean=0.72ms`(≈基线)。
### C. ① 的结论:主修复(`driftLimit` + 分帧滚动)**已经正确**,之前的 FAIL 是度量错了
`RollWideBuffer` 用 `kLayerRollMaxStepPixels=240` 每帧只把超限的位移往回滚最多 240px,
其余位移仍由 `BlitWideBuffer` 的 `srcX` 承担;因此 `|shift|` 可以长期停在 ±(margin-24) 附近,**不会空白**。
之前的探针拿"适应视图时的墨迹"当基准比 → 平移本身会移出数据范围(时间轴外没有采样),
墨迹自然下降 → 误报。
**新的正确判据(已固化进 `panflash` 与 `stress`)**:
同一次拖拽中,把当前帧与**同一视界的整幅强制重绘**(`m_sceneValid=false` + 清空 `m_layerFront`)比墨迹,
比值 < 0.9 才算"丢波形"。三条拖拽模式(120px/帧、90×7 斜向、260px/帧)全部 `badFrames=0`,
`worstRatio 0.966~0.999`。
### D. 新增 `stress` 探针(随机组合压力测试,用户第 4 项要求)
1 个 `VERY_BIG_DATA`(5×2576 万采样)+ 6 个 MF4 分组(共 ~1500 信号)、41 个可见网格;
种子固定的随机序列覆盖:中键拖拽(含空白判据抽样)、滚轮缩放 / 分布缩放、悬停扫掠、组折叠展开
(**每帧都把鼠标扫过正在出现/消失的行**,并校验 `m_contentHeight` 单调)、适应视图、面板滚动条拖拽、
勾选/取消信号、标签页新建/切换/关闭。判据全部**相对**(同一次运行内测基线),
避免机器状态导致的绝对阈值漂移。
实测:`ops=26 frames=209 mean=8.53ms worst=44.87ms (toggleSignal) overBudget=0`;
`pan blank check: 90 lane samples, 0 below 80%`;`group animation: heightRegressions=0`。
最慢帧是 `toggleSignal`(勾选信号 ⇒ 41 个网格全部重画信号曲线像素),属于用户明确排除的"需要重绘曲线"场景。
### F. 验证(全部离屏,不占用鼠标/键盘/屏幕)
- `panflash` **19/19**(平移无丢波形 + 悬停 4ms + 列表悬停 0.7ms + 光晕几何窗口化精确);
- `stress` **6/6**;回归:`verify` 49/49、`panelanim` 158/158、`hoverdrag` 20/20、`gridanim` 93/93、
`lanescroll` 23/23、`datacheck` 69/69;
- `make release TARGET_FILE_NAME=TargetCheck` 通过(`Target.exe` 被运行中的实例占用,不能直接覆盖)。
### G. 教训(新增规则)
1. **GDI+ 的"加粗折线描边"绝不能用于逐像素列的包络带几何**:代价随"线段数 × 覆盖行数"爆炸;
稠密信号的光晕/高亮一律用 `FillPath`(同一几何)+ 鼠标附近窗口裁剪。
2. **任何"每帧随鼠标变化"的绘制都必须 O(光标附近)**,不能 O(整条曲线);否则一次悬停就能把 60fps 打成 5fps。
3. **性能与正确性判据必须是"同一次运行内的相对量"**:绝对 ms / 绝对墨迹都会随机器状态和视界内容变化而误报。
4. 平移正确性的唯一可信判据是"与同一视界的整幅重绘对比",而不是"与某个固定基准帧对比"。
---
## 本轮(2026-10-18 第二轮):悬停光晕恢复"整条发光" + 信号列表卡顿真因(行位图缓存)
用户报告:① 上一轮的光晕修复把高亮变成了"像选区放大的长方形色块"、联动也失效;② 信号选择区
(**只有文字**的面板)滚动/展开动画依然卡顿,反复"展开→切组复选框→收起→切回"必现。
### A. ① 的真因:上一轮把光晕裁到鼠标附近 ±72px
上一轮为了让"鼠标划过巨型信号"不卡死,把光晕塞进 `kHaloWindowHalfWidth=72` 的窗口。
但光晕的**语义是"整条曲线一起发光"**:对满幅方波(编码器)来说,一列包络本来就跨满网格高,
裁成一个 144px 宽 × 满高的窗口 ⇒ 屏幕上就是一个**长方形色块**,看起来像选区框。
本轮:`PaintSignalHaloFor` 恢复**整条**几何(包络带 `FillPath` / 折线 `DrawLines`),
代价改由 **`PaintSignalHaloCached`** 承担:
- 光晕的几何只取决于「信号 + 视界 + 网格几何 + 样式」,**与鼠标位置无关** ⇒ 渲染一次进离屏位图;
- 每次贴图只做一次 `AlphaBlend`(源位图**预乘 alpha**,见下);
- 键与 `AcquirePolyline` 同源(`PolylineCacheEntry` 的键字段 + 样式字段),
**不含网格屏幕位置**(滚动网格只需按新位置贴同一张位图)。
性能:210ms/帧(描边整条)→ **整条包络带填充一次 15ms + 每帧贴图 ~2ms**;
`halocheck` 校验 `outsideBand=0`、`outsideLane=0`、bbox 覆盖整条曲线(不是中段方块)。
### B. ② 的真因有三个,都不是"面板没优化"
用新增探针 `panelstutter`(129 个可见网格 + 反复"展开→切组复选框→收起")量出来的:
1. **面板每帧重画 ~36 行,每行 ~0.35ms**(十来个 GDI+ 原语)⇒ `panel.Paint` **24~35ms**。
一行内容在动画期间**一个像素都没变**,变的只是 y 坐标。
2. **动画帧里还在预热网格图层**:`WarmLaneLayersStep()` 每帧重建 1 个网格图层,
单个 2500 万采样信号 **10~30ms** ⇒ 动画帧被硬生生拉长(用户看到的"跳帧")。
3. **勾选一个分组会把上百个离屏网格排进待重画队列**,随后每帧补 1 个 × 10~30ms ⇒
**数秒的持续掉帧**(`RollWideBuffer` 对离屏网格也 `QueueLaneRebuild`)。
修法:
1. **行位图缓存**(`SignalPanel::PaintRowCached`):按"行的可见内容"做键,把整行渲染进
离屏 DIB,命中时一次 `BitBlt` 贴出。`panel.Paint` **24~35ms → 5~6ms**。
- ⚠️ 位图必须是 **DIB + 内存 DC**,不能用 `gd::Bitmap`:GDI 在 GDI+ 位图的临时 HDC 上
**会关闭 ClearType**(整列文字退化成灰度抗锯齿、观感变细)。
- ⚠️ 行内几何必须**在 `RowRect` 里平移**(`m_rowRenderActive`),不能用
`TranslateTransform`:GDI 文本不认 GDI+ 世界变换 ⇒ 文字会画到小位图之外(实测:只剩复选框与色块)。
- ⚠️ 渲染到行位图时**不能按列表矩形裁剪**(`m_rowClipRender`),裁剪留到贴图时的 DC 上:
`GetHDC()` 出来的 DC **不继承 GDI+ 裁剪**,必须自己 `SaveDC + IntersectClipRect`(折起动画时
行要滑进组名栏、不许画到组名上)。
- 缓存数量上限 192 行(≈10MB),超过即整体作废。
- 静态画面**逐像素一致**(面板区域 54/275200 像素不同,最大差 75 —— 滚动条滑块抗锯齿)。
2. **UI 动画期间不预热网格图层**(`PlotView::SetAnimationBusy`,由 `Application::Render` 传入):
预热被"推迟"而不是取消(队列保留),动画结束后按既有预算继续。
3. **待重画队列只保留可视 + 预取带内的网格**(`RenderPendingLaneRebuilds` 丢弃离屏项);
`RollWideBuffer` 也不再登记离屏网格 —— 它们真正进入视区时会按新视界重新登记。
实测(`panelstutter`,同一台同一次运行):展开动画 **p50 35ms → 13ms**、收起 **33ms → 13ms**、
面板绘制 **24~35ms → 5~6ms**、动画帧里"重建网格图层"次数 **56 → 0**。
### C. 试过但放弃的两条路(避免以后重走)
- **`ExtTextOutW` 替代 `DrawTextW`**:约快 2~4 倍,但**光栅化结果不同**(面板区域 10% 像素差异、
亚像素边缘差 1px)⇒ 全体界面文字观感变化,放弃。
- **用 `gd::Bitmap` 做行位图**:文字丢失 ClearType(见 B.1)。
### D. 验证(全部离屏,不占用鼠标/键盘/屏幕)
- 新增 `panelstutter`(8/8):展开/收起动画帧不重建网格图层、动画 p50 ≤ idle+12ms、
行悬停仍会重绘该行(行缓存随悬停失效)。
- 新增 `halocheck`(16/16):悬停必命中(60/60 采样位置)、光晕沿整条曲线且贴合带宽、
列表行悬停联动、单信号满高场景耗时受控。
- 回归:`verify` 49/49、`panelanim` 158/158、`hoverdrag` 20/20、`gridanim` 93/93、
`lanescroll` 23/23、`datacheck` 69/69、`edgecheck` 46/46、`errcheck` 19/19、
`panflash` 19/19、`stress` 6/6。
- `make release` / `make debug` 零警告(`TARGET_FILE_NAME=TargetCheck/TargetCheckD`)。
### E. 教训(新增规则)
1. **"高亮提示"的语义是"整条曲线发光"**:任何把高亮裁成"鼠标附近一段"的做法,在满幅信号上
都会退化成"长方形色块"(用户一眼就看出不对)。性能要用**缓存**解决,不能改变语义。
2. **GDI 文本的三种"隐形陷阱"**:① 不认 GDI+ 世界变换(要平移几何);② 不认 GDI+ 裁剪区
(要自己 `IntersectClipRect`);③ 在 GDI+ 位图的临时 HDC 上**关闭 ClearType**
(要改用 DIB + 内存 DC)。
3. **"面板只有文字却卡顿"多半不是文字本身的锅**:先量"每行成本 × 可见行数",
再量"这一帧是否顺带在做绘图区的重活"(图层预热 / 队列排空)。
4. **动画帧里绝不做可延迟的整帧级工作**(图层重建、队列排空);这类工作一律推迟到动画之后。
---
## 本轮(2026-10-18 第三轮):悬停高亮恢复旧版观感 + 支持 MATLAB `.mat`(v7.3 / HDF5)
用户报告:① 全部信号分网格显示时,鼠标悬停的高亮「几乎看不到、就像曲线换了个颜色」,
只有放大到一定程度(走折线描边路径)才是旧版效果 —— 要参考提交 `f845616` 的观感;
② 需要支持 `TestData/data-sample.mat`(`Simulink.sdi.view` 导出的 `timeseries`),
并且**嵌套分组**要变成多个组(`A/A1/XYZ` → 组名 `A-A1`、信号 `XYZ`)。
### A. ① 的根因:包络带光晕与曲线**逐像素同形**
调色板第 1 色**就是主题强调色**(`0xFF268BD2`),所以「用强调色给曲线上一层半透明」在
曲线像素上完全看不出变化。旧版之所以有「外延描边」,是因为它用 `lineWidth + 4` 的笔
沿折线描粗 —— **笔画向两侧各外扩 2~3px**,而那圈外扩落在背景上才显眼。
上一轮为提速把光晕改成「按包络带原始几何 `FillPath`」,正好丢掉了这圈外扩 ⇒ 用户看到
「只是曲线换了个颜色」。本轮(`PlotViewPaint.cpp::PaintSignalHaloFor`):
1. **逐列成对填四边形**,上下各外扩 `grow = (lineWidth + 4) * scale / 2`,左右也外扩 `grow`
(竖向跃变处旧版 5px 笔涂出的正是那条整高色带,只填 1px 列区间看不出来);
2. **不能把整条包络拼成一个多边形**:满幅方波的相邻列区间互相交叉,缠绕填充规则会在
交叠处相互抵消 ⇒ 填充变成一条条竖缝(实测只覆盖约一半带宽);
3. alpha 从 `0x55` 调回 **`0x66`**(与旧版描边笔一致)。
代价:光晕仍然只在「换信号 / 换视界 / 改样式」时重建一次(~40ms),此后每帧只 `AlphaBlend`
(~2ms)。三维校验(新增 `haloshot` 探针 + `f845616` 工作树对照构建):
| 版本 | 变化像素 | 覆盖外延 |
| --- | --- | --- |
| f845616(旧版) | 73726 | 有(跃变处整高色带) |
| 上一轮 | 13529 | 无(只有带宽内部) |
| 本轮 | 74916 | 有(与旧版逐像素目视一致) |
### B. ② MATLAB 7.3 `.mat`(HDF5)支持
`MATLAB 7.3` 的 `.mat` 文件头是 `HDF5`(user block 512 字节)。项目不引入第三方库,
新增两个文件实现**只读 HDF5 子集**:
- `Utilities/Inc/MatH5.hpp` + `Utilities/Src/MatH5.cpp`:超级块 v0/v2、对象头 v1(含续块)、
符号表(B-tree v1 + 本地堆)与 link 消息、数据空间 v1/v2、数据类型 v1、数据布局 v3
(紧凑 / 连续 / 分块),分块数据集支持 **deflate**(zlib)。
- `Utilities/Inc/MatLoader.hpp` + `Utilities/Src/MatLoader.cpp`:MATLAB 语义层。
两类内容:
1. **Simulink SDI / `SimulationData.Dataset`**(识别 `#refs#` + `#sigstream#`):
- `#refs#` 里的每个元素有 `ElementType`(`signal` / `dataset`)、`Name`、`Values`;
- 信号(`timeseries`)的 `Values/DataR2`、`Values/TimeR2` 是 **`#sigstream#/<n>` 的流编号**
(数据流与时间流成对出现);`#data#` 是 `uint8` 字节流,按 8 字节一组解释为 double,
长度与 `#length#`/`#sampleSize#` 一致;
- 子数据集元素的 `Values` 是**对象引用数组**(子元素),据此还原嵌套树;
- **分组名 = 从根到信号的各级数据集名用 `-` 连接**(用户 2026-10-18 要求),
信号名取元素的 `Name`(空则退回 timeseries 的 `Name`,再退回 `信号N`)。
2. **普通 v7.3 变量**:顶层数值矩阵按「列 = 信号、行 = 采样」读取(时间轴 = 采样序号),
结构体按字段递归。
`BuildSignalGroup` 只能产出一个分组,因此把加载入口扩成**多分组**:
`LoadSignalSourceGroups()`(`DataLoader`)→ `AsyncLoadState::groups` → `Application::LoadFile`
逐个 `AddGroup`;其它格式仍然只有一个分组(名字取文件名)。
### C. 验证(全部离屏,不占用鼠标/键盘/屏幕)
- 新增 `haloshot`(截图探针,与 `f845616` 工作树对照:旧/新变化像素 73726 / 74916);
- 新增 `matcheck`(15/15):4 个分组、8 条信号、时间轴单调、采样数正确;
- 新增 `matui`(11/11):`Application::LoadFile` 端到端把 4 个分组加进数据层与信号列表;
- 回归:`verify` 49/49、`panelanim` 158/158、`gridanim` 93/93、`hoverdrag` 20/20、
`lanescroll` 23/23、`datacheck` 69/69、`edgecheck` 46/46、`errcheck` 19/19、
`halocheck` 16/16、`panflash` 19/19、`panelstutter` 8/8、`stress` 6/6;
- `make release` / `make debug` 零警告;Makefile 与探针构建脚本均补 `-lz`。
### D. 教训(新增规则)
1. **「高亮/选中」这类叠加层必须自己带"外扩"**:若叠加色恰好等于元素本身的颜色,
逐像素同形的填充等于没有效果;旧实现的观感来自**描边笔宽**,等效做法是几何外扩半个笔宽。
2. **缠绕填充规则( winding )不能用于自交的包络带**:相邻列区间交叉时正负缠绕会抵消,
必须逐列成对填充(每段都是简单多边形)。
3. 读 HDF5 时**对象头里的 `headerSize` 才是主消息块的长度**(消息数只是总数),
否则消息多、需要续块的对象会读到块外垃圾,字段全部解析失败。
4. MATLAB 写出的**分块维度可能与数据空间维度维数不同**(1 维数据集的分块是 `[N,1]`),
按"右侧补 1"对齐即可解码。
---
## 本轮(2026-10-19):Release 产物 1.74MB → 796KB(只用 2 个真实原因)+ `.mat` 加载进度
用户报告:① 只加了一个格式支持,Release 二进制却从 ~787KB 涨到 **1.74MB**,要求压回 **< 800KB**;
② 加载 `.mat` 时遮罩层的「已加载大小 / 总大小 / 百分比」都不更新,界面只是原地等待。
### A. ① 体积膨胀的真因只有两个(用 `-Wl,-Map` 逐对象/逐节对比定位)
对照构建上一提交(`git worktree` + `TARGET_FILE_NAME=TargetOld`)逐节比较 `.text/.data/.rdata/...`:
1. **`<filesystem>`(致命,+~900KB)**:`MatH5::Open` 为了取文件大小用了
`std::filesystem::file_size`。MinGW 的 libstdc++ 会因此链入
**`fs_path.o` / `fs_dir.o` / `fs_ops.o` / `c++locale.o` / `codecvt.o` / `collate_members*` /
`cow-wstring-inst.o` / `cxx11-wlocale-inst.o` / `fstream-inst.o` / `cp-demangle.o`** 等
整套 locale/iostream 机器 —— 成员数 415 → 538,`.text` 615KB → 654KB,且把
`.bss` 里 64KB 的静态表挤成 `.data`(`.data` 从 1.8KB 涨到 73.8KB)。
修法:改用 `_wfopen_s` + `_fseeki64/_ftelli64` 取大小(零依赖)。
2. **`-lz`(~46KB)**:只需要「解开 HDF5 分块」这一功能,不值得链整个 zlib。
修法:`MatH5.cpp` 内置一份紧凑 inflate(RFC1950/1951,含固定/动态 Huffman、
存储块、长度-距离回抄),同时把 `-lz` 从 Makefile 与探针脚本移除。
顺带修掉一处**体积隐患**:`UploadHaloLayer` 里 64KB 的预乘 alpha 查表被换成
`(v*a+128)*257>>16` 的乘法-移位(无除法、无表)。⚠️ 若将来开 `-fdata-sections`,
函数内静态数组会被放进 `.data$_ZZ...`(**实测凭空 +72KB 文件体积**),这条也一并避开了。
### B. 剩下 ~40KB 的正当成本与最终压缩手段
去掉上面两项后仍是 825,856(旧版 786,944,+38.9KB = MatH5 15.4KB + MatLoader 12.5KB +
对齐/导入表,属"新增一个格式解析器"的正常成本)。要压到 <800KB,采用两项**不改变行为与观感**的措施:
1. **图标资源无损重编码**:`Resources/JustDataInspector.ico` 里 64/48/32/16 四帧原本是
未压缩的 32bpp DIB(合计 32KB),改用 GDI+ 重编码为 PNG 帧(ICO 的 PNG 条目 Vista+
全支持,本文件本身就有 256 PNG 帧):**43,334 → 17,404 字节**。
像素逐点校验:差异像素 ≤85 个、最大分量差 ≤3(GDI+ 编码取整),肉眼不可辨;
`matui` 探针从**真实产物**里 `LoadImageW` 逐一取 16/32/48/64 帧,全部 ok。
2. **`RELEASE_OPT` 由 `-Os` 改为 `-Oz`**(省 ~6KB)。实测动画帧 p50 7.73ms(-Os) vs
7.77ms(-Oz),无可辨识差异(本项目热点全在 GDI/GDI+ 绘制)。
⚠️ 试过并**放弃**的手段:`-ffunction-sections -fdata-sections + -Wl,--gc-sections` 反而 +9KB
(分节后每函数 16 字节对齐 + 静态量落 `.data`);LTO 在该工具链上 **ICE** 崩溃;
`-fno-asynchronous-unwind-tables` 链接失败;`-fno-exceptions` 会破坏 `DataModel` 的
`catch (std::bad_alloc)`。
最终:**`Build/Release/Target.exe` = 795,648 字节**(< 800KB,约 4.4KB 余量)。
### C. ② `.mat` 加载进度不更新
真因:`LoadMatSourceGroups` 只在**结束时**回调一次 `ratio=1.0` 且从不设置 `hasBytes`;
读文件(42MB)与解码 19 个样本流(解压 + 拷贝,占大头)期间进度恒为 0 ⇒ 遮罩层
一直显示 0.00%、总大小未知。
修法(`MatH5` + `MatLoader`):
- `MatHdf5::Open` 新增 `OpenProgress(bytesRead,totalBytes)`,**按 4MB 分块读入并回报**,
读完即得到"已加载 = 总大小";
- `MatLoader` 用 `ProgressReporter` 把进度拆成 `0.00–0.55 读文件`(`hasBytes=true`)
与 `0.55–0.98 解码样本流`(每解完一个流推进 `done/total`),末段 1.0;
- 取消(回调返回 false)与出错分开处理:取消时清空 `error`,不弹错误框。
实测(`matcheck`):回调 **29 次**、其中字节数变化 **11 次**、比例单调、终值 1.000、
`bytes=42,386,344/42,386,344`(= 文件大小);`matui` 进一步断言
`Application` 的 `m_loadingBytesRead == m_loadingTotalBytes == 文件大小`(遮罩层拿到的值)。
### D. 验证(全部离屏,不占用鼠标/键盘/屏幕)
- `matcheck` **21/21**(新增进度断言)、`matui` **15/15**(新增图标 + 加载字节断言);
- 回归:`verify` 49/49、`panelanim` 158/158、`gridanim` 93/93、`hoverdrag` 20/20、
`lanescroll` 23/23、`datacheck` 69/69、`edgecheck` 46/46、`errcheck` 19/19、
`halocheck` 16/16、`panflash` 19/19、`panelstutter` 8/8、`stress` 6/6;
- `make release` / `make debug` 零警告;`Build/Release/Target.exe` 795,648 字节。
### E. 教训(新增规则)
1. **MinGW 下 `#include <filesystem>` 是体积炸弹**(连带 locale/iostream/demangle 整套,
+0.9MB);只需要"取文件大小"时用 `_wfopen_s + _fseeki64`。
2. **为一个功能链一整条库前先算账**:`-lz` 46KB vs 自带 inflate 3~4KB;
能用几十行自实现的就不要链。
3. **判断二进制膨胀要看节表**(`objdump -h` + `-Wl,-Map` 逐对象求和),
只看 `.o` 大小会被"归档成员/对齐/`.data` 里的静态表"骗过去。
4. **图标资源可以无损重编码**(DIB 帧 → PNG 帧),同一份像素省 60% 体积;
用探针从**真实产物** `LoadImageW` 逐尺寸校验,而不是只看文件能编译。
5. **任何"长时间解析"都要按阶段回报进度**(读字节 + 每个解码单元),
且 `hasBytes` 只在真正有字节可报的阶段置真,否则遮罩层会停在 0%。
---
## 本轮(2026-10-19 第二轮,本机):列表内联编辑看不到插入符/选区 + UI 基础层迁入 `Components/Shared`
> 用户报告两件事:① 信号列表里**组名**双击进入编辑后与其它输入框状态不一致 ——
> 无法部分选择、鼠标框选无反应、左右方向键也不移动插入符;② `Utilities/{Inc,Src}` 里的
> `ColorPickerPopup` / `PopupMenu` / `TextEdit` / `Theme` 等其实是会绘制的 UI 组件,应归到
> `Components/Shared`。
> 验证:新增离屏 `.preflight/editcheck`(`SW_HIDE` + 合成消息 + `BELOW_NORMAL`)**20/20**;
> **A/B:修复前 6 项如实失败**(键不变 + 屏幕不重绘)。`make release` / `make debug` 零警告。
### A. ① 的根因:行位图缓存的键漏了「插入符与选区」
- 信号列表为了流畅性把**整行**渲染进离屏 DIB 缓存(`SignalPanel::PaintRowCached`,`24f0bf8` 引入),
命中时一次 `BitBlt`。但 `RowCacheKey()` 在编辑态只混合了 **`editing` 标志 + `m_edit.Text()`**。
- 于是 **方向键 / 鼠标框选只改变 `TextEdit` 的 `caret / anchor`,文本没变 ⇒ 键没变 ⇒ 贴回旧位图**
⇒ 屏幕上插入符与选区完全不动(**输入字符**时才刷新,所以只表现为“不能部分选择/光标不动”)。
绘图区的光标时间输入没有行缓存,所以一直正常 —— 这正是用户说的“状态不一致”。
⚠️ 信号名编辑走同一套行缓存,同样有此缺陷(只是用户先注意到组名)。
- ✓ 修法:两个分支(`GROUP_TITLE` / `SIGNAL_NAME`)在编辑态把
`Caret()` / `SelectionBegin()` / `SelectionEnd()` 一并 mix 进键。
- ✓ 顺带统一字体:新增 `SignalPanel::EditFieldFont()`(分组名 = `uiSmall`,其余 = `ui`),
供 `OnEditMouseDown` / `OnEditMouseMove` 的 `UiEditOffsetAt` 使用 ——
绘制(`PaintGroupRow` 用 `uiSmall`、`PaintSignalRow` 用 `ui`)与**插入符换算必须同字体**,
否则“点哪儿插入符落在哪儿”这个不变量会被破坏。
### B. ② 的落地:`Utilities` 只保留纯数据/逻辑
| 旧位置 | 新位置 |
| --- | --- |
| `Utilities/Inc/Theme.hpp` + `Utilities/Src/Theme.cpp` | `Components/Shared/{Inc,Src}/Theme.*` |
| `Utilities/Inc/PaintUtil.hpp` + `Utilities/Src/PaintUtil.cpp` | `Components/Shared/{Inc,Src}/PaintUtil.*` |
| `Utilities/Inc/TextEdit.hpp` + `Utilities/Src/TextEdit.cpp` | `Components/Shared/{Inc,Src}/TextEdit.*` |
| `Utilities/Inc/PopupMenu.hpp` + `Utilities/Src/PopupMenu.cpp` | `Components/Shared/{Inc,Src}/PopupMenu.*` |
| `Utilities/Inc/ColorPickerPopup.hpp` + `Utilities/Src/ColorPickerPopup.cpp` | `Components/Shared/{Inc,Src}/ColorPickerPopup.*` |
- **为什么连 `PaintUtil` 一起搬**:`PaintUtil.hpp` 依赖 `Theme.hpp`(`ToGdiColor` 等)。
若只搬 `Theme`/`TextEdit`,`Utilities` 反而要包含 `Shared` 的头文件 ⇒ **反向依赖**,
违反第 88 条的单向分层。搬完之后 `Shared` 是自洽的“UI 基础层”:
`Theme`(设计令牌/颜色)→ `PaintUtil`(低层原语)→ `UiControls`(控件)→ `PopupMenu`/`ColorPickerPopup`。
- 依赖方向不变:**`Components/* → Components/Shared → Utilities`**;`Utilities` 现在只剩
`DataModel` / `DataTable` / 各 `*Loader` / `MatH5` / `Utf8Util`,**不得**再放 `gdiplus.h` 系代码。
- 搬移手法:`git mv`(保留历史)+ **Makefile 无需改动**(`#include "X.hpp"` 平铺,两个目录都已在
`CXX_INCLUDES_PATHS/CXX_SOURCES_PATHS`)。⚠️ 必须删掉 `Build/Release|Debug` 里对应的
旧 `*.o` / `*.d`(`.d` 里记着旧路径,会让 make 误判/漏依赖)。
- 文档里 §11.2 第 88 条已按本表更新(历史章节里出现的 `Utilities/PaintUtil.*` 等仅作历史记录)。
---
## 本轮(2026-10-19 第三轮,本机):组名编辑穿透 + 浮动面板 GX 徽标 + 最小网格高度
> 用户三项需求:① 组名处于编辑态时,在编辑框内双击 / 快速单击 / 框选会让**分组展开或收起**;
> ② 所有实验性功能浮动面板左上角的 `GX` 标签**没有悬停提示**,且点击它应把目标网格滚到视界内;
> ③ 加载 MF4 全部信号后网格太窄,最小高度提到现在的 **1.5 倍**。
> 验证:新增离屏 `.preflight/round3check` **31/31**(真实 `TestData/MF4-Record.MF4`:128 网格);
> A/B 禁用双击保护 ⇒ 1 项如实失败;回归 `.preflight/editcheck` 20/20、`.preflight/check` 137/137;
> `make release` / `make debug` 零警告。
### A. ① 的根因是**两处**“行点击抢走了文本操作”
1. **行内边距**:行高 > 输入框高度(`RowNameRect` 上下各留 3 逻辑像素)。鼠标起点落在这条内边距上时
`OnEditMouseDown` 判“不在输入框内”而返回 false ⇒ 宿主把这次按下交给 `SignalPanel::OnLButtonDown`
⇒ 命中分组行的 `BODY` ⇒ **展开/收起**(框选时之所以会折叠,就是起点偏出输入框 1~3px)。
2. **快速单击 / 双击**:窗口带 `CS_DBLCLKS`,编辑态下快速再点一次会被合成为 `WM_LBUTTONDBLCLK`。
`DoubleClickIsPlainPress` 对输入框返回 false(本意是保留“选词”语义),于是走到
`SignalPanel::OnDoubleClick` —— 而它**没有编辑态保护**,直接进入分组行双击逻辑
⇒ 快速双击进改名 / 慢双击**再切一次折叠**。
**修法**(不是给这两条路径各打一个补丁,而是给“名称编辑”划出一个明确的吸收区):
- 新增 `SignalPanel::NameEditAbsorbRect(field)` = **输入框横向范围 × 整行高度**(与 `RowRect` 同源)。
- `OnLButtonDown`:编辑态且命中吸收区 ⇒ 直接 `OnEditMouseDown(x, y)` 并 `return true`(**绝不触发行点击**)。
- `OnEditMouseDown`:命中吸收区但不在输入框内时,把落点**夹回输入框**再算插入符/开始拖拽
(保证与绘制使用同一套几何,插入符落点不偏移)。
- `OnDoubleClick`:编辑态且命中吸收区(输入框或整行)⇒ **直接返回 false**(不折叠、不再次改名)。
- ⚠️ 横向仍严格限定在名称区域:同一行的复选框 / 「N 项」/ GX 徽标 / ✕ **继续可点**(不然编辑时无法关分组)。
### B. ② 浮动面板的「GX」徽标
- `EdgePanelLayout` 新增 **`badge`** 矩形,由 `EdgePanelLayoutFor` 统一算出 ⇒
**绘制 / 悬停提示 / 手型光标 / 点击联动**共用同一个几何(此前徽标矩形只在 `Paint` 里就地算,无法命中)。
- 悬停:`PlotView::m_hoverPanelBadgeLane/Kind` 单独记录(面板像素在图层里,必须靠状态触发重绘);
徽标本身画强调色描边 + `accentSoft` 填底;`QueryCursor` 返回 `IDC_HAND`。
- 提示气泡:`PaintPanelBadgeTooltip`,锚定徽标下方(放不下则翻到上方),文案
`G12 · 点击滚动到该网格` / `G12 · 已在视界内`。
⚠️ **必须在全部面板像素存进缓存之后绘制**(`PaintEdgeCursor` 末尾),否则气泡会被烘进面板位图。
- 点击:调用 `PlotView::ScrollLaneIntoView(laneIndex)` —— 已完整可见时它自身返回 false(不动),
否则启动与“点选信号联动滚动”同源的 ease-out 平滑动画;并给一句 `正在滚动到 G12 网格…` 提示。
- ⚠️ 命中顺序:`close` → **`badge`** → `title`。徽标在标题栏矩形内部,放在 `title` 之后就会被当成“拖面板”。
- `PanelSlotKey` 也 mix 了 `badge`(几何参与像素内容)。
### C. ③ 最小网格高度 54 → 81 逻辑像素,并修掉二次缩放
- `m_minLaneHeight` 的语义是**逻辑像素**,使用处才乘 `m_scale`。旧实现 `Layout()` 里写成
`54.0f * m_scale`,而 `BuildLaneVisuals()` / `FitLaneHeight()` 又各乘一次 ⇒ 125% 缩放下
实际最小值是 `54 × 1.5625 ≈ 84.4px`(而非 54×1.25),且与“是否需要滚动条”的判定
(只乘一次)不一致 ⇒ **可能出现“网格已经溢出、却没有滚动条”**。
- 现为 `m_minLaneHeight = 81.0f`(用户要求 1.5 倍),`Layout()` 不再预先乘缩放,
滚动条判定改 `laneCount * m_minLaneHeight * m_scale`;其余两处保持 `* m_scale`。
- 实测(本机 125%、窗 1400×900 逻辑、MF4 全部 128 信号各自一格):
`lane height = 101.25px = 81 × 1.25`,`content = 12960px`,滚动条出现且可滚动。
---
## 本轮(2026-10-19 第四轮,本机):`.mat` 流式解码 + MDF4 数据列表 + 光标标记
> 用户三项需求:① `BIG_MAT.mat`(1.29GB,SDI 导出)必须“边解析边填充”,不能再整文件读入;
> 进度要真实反映读取过程并完成相应优化;② `Problem-MF4.mf4` 在 SDI 里能读出 8 个信号,
> 本项目只读出 4 个(未实现的数据块特性);同时确认该文件里 `gVP_WindowLearnSt[0]` /
> `gVR_WinOpenReq[0]` 是否带值枚举文本(**只回答,不改代码**);③ 光标数据点圆点被底部
> 时间读数面板盖住时,改为向下的三角。
> 验证:新增离屏 `.preflight/round4check` **34/34**;A/B 分别 2 / 3 项如实失败;
> 回归 `round3check` 31/31、`editcheck` 20/20、`check` 137/137;`make release`/`make debug` 零警告。
### A. `.mat`:内存映射 + 逐 chunk 直接填充
**旧链路的三份内存**(1.29GB 文件实测私有内存 +1234MB):
1. `MatHdf5::Open` 把整个文件 `fread` 进 `std::vector<uint8_t> m_data`;
2. `DecodeChunked` 把**整个数据集**解压成 `out.data`(未压缩字节,与 double 输出同样大);
3. `MatLoader::ReadStream` 再 `memcpy` 成 `std::vector<double>`;`streams` 缓存所有流,
`AppendSignal` 又整份复制一次;且**每条信号各建一个 `TableBlock`(各带一份时间列)**。
**新链路**:
| 位置 | 做法 |
| --- | --- |
| `MatHdf5::Open` | `CreateFileW` + `CreateFileMappingW` + `MapViewOfFile`;`m_data` 改为 `MappedBytes`(只读视图,支持 `size/data/begin/end/[]`,解析代码零改动) |
| `ReadDatasetInfo` | 只解析元信息(dims/type/layout/chunk/deflate/紧凑字节),不解码数据 |
| `StreamDataset(ByteSink)` | 连续布局按 4MB 切片、分块布局**逐 chunk** inflate 后回调;回调带 `elementOffset` |
| `ReadNumbers` | 流式转换成 double(不再生成整块字节数组) |
| `ReadDoubleStream` | SDI `#data#`(uint8 字节流)按 8 字节一组**直接写** double;7 字节尾巴处理非 8 对齐 chunk |
| `MatLoader` | 先收集信号清单 → 按(分组, 时间流)合并 `TableBlock`;时间列**按内容去重**;数据列 `std::move` |
**性能/内存实测(`BIG_MAT.mat`,5 信号 × 16,142,471 采样)**:
| 指标 | 改造前 | 改造后 |
| --- | --- | --- |
| 加载耗时 | **117.3s** | **12.5s** |
| 私有内存增量 | **+1233.9MB** | **+740.4MB**(最终列数据 738.9MB) |
| 单流解压吞吐 | ~11MB/s | ~110MB/s |
其中 **~10 倍**的吞吐提升来自一处关键修正:SDI 的 `#data#` 是 `dims=[N]`、chunk `[N,1]`,
归一化后 rank=2 ⇒ **每行只有 1 字节**。旧代码每行新建一个 `coordinate` 向量并回调一次
(上千万次小 memcpy + 分配)。现在把“目标连续的行”合并成一次回调(典型整块 1 次)。
另外把 Huffman 解码改成 9 位快查表(对高压缩比数据有效;本例数据基本不可压缩,
大部分 chunk 走“未压缩”直通,因此主要收益来自行合并)。
**进度**:映射阶段 0–0.02(`hasBytes` 与总大小立即就位),逐条样本流解码 0.02–0.98,
收尾 1.0。⚠️ 旧实现把 0–0.55 分给“读文件”,改映射后那一段几乎没有耗时,必须重新分配权重,
否则进度条会“瞬间跳到 55% 再慢慢走”。
**正确性回归**:42MB 的 SDI 样例仍是 **4 分组 / 8 信号**,行数 754 / 801 / 12640 / 523865 全部一致。
### B. MDF4:`##DL` / `##HL` / `##DZ` 数据块
`Problem-MF4.mf4` 的结构(`HD=64`):
```
DG1 data=##DT cycle=94 → 4 个信号(gVP_WindowLearnSt[0/1]、gVP_WinStroke[0/1])
DG2 data=##DL cycle=9396 → 4 个信号(gVP_WinRealPosition[0/1]、gVR_WinOpenReq[0/1])
##DL → ##DT(131040B) + ##DT(504B) (合计 131544 = 9396 × 14B)
DG3 data=0 cycle=0 (空组,一个字符串通道)
```
旧实现要求 `DG` 的 data 链接必须是 `##DT` ⇒ **DG2 整组被跳过** ⇒ 只有 4 个信号。
修法:
- `CollectDataSegments`:递归展开 `##DL`(数据列表)/ `##HL`(头部列表),叶子识别
`##DT`(普通)、`##DV`(数据值)、`##DZ`(压缩);
- `ReadRecordSegments`:跨段拼接记录(`carry` 缓冲)、压缩段先 `InflateZlib`、
非压缩段仍按 4MB 分块读(不整段分配);
- 压缩段的记录长度不能用“载荷 ÷ 周期数”(那是压缩后长度)⇒ 改用
`max(cg_record_id_len*8, 各通道 bitOffset+bitCount)` 向上对齐到 4 字节。
- `##SD`(已排序数据)仍未实现(不在本文件里;遇到会跳过该组)。
实测:**8 个信号**,`gVP_WindowLearnSt[0]` 94 行、`gVR_WinOpenReq[0]` 9396 行,
时间列单调且基本全有限,与 Simulink SDI 显示一致。
### C. 光标数据点被时间读数面板遮住 ⇒ 向下三角
- 新增 `MarkerCoveredByCursorLabel(marker, radius, hit)`:圆心落入面板,或圆的外接矩形与面板
重叠 ≥ 50% 即视为“看不见”。`m_cursorLabelRects` 里已提前登记时间读数与 Δt 胶囊。
- 新增 `CoverHiddenMarker(marker, radius, clipTop)`:把标记置为 `offDown`,锚点放到面板上沿
上方 `kMarkerTriangleDepth/2 + 1`(三角尖端朝下,语义 = 数据点在下方)。
在 `PaintCursor` 的**原始信号**与**派生曲线胶囊**两处调用。
- ⚠️ 像素级验证必须钉在**标记锚点附近的小框**:取值胶囊也用信号色作底,
数“面板上方的信号色像素”会把漏画三角误判为通过(本轮 A/B 实测 255 px 假绿)。
### D. 文件里是否存在枚举文本(只回答,不改代码)
**存在**。两个通道的 `##CC` 都是 `cc_type = 7`(值→文本表),结构为
`links[0..3] = 表名 / 空 / 注释 / 空`,`links[4 + i]` 对应 `cc_val[i]`,最后一个是反向换算 `##CC`。
`gVP_WindowLearnSt[0]`(`valCount=5`,注释 `Enumerated data type: WMRLearnSts`):
| 值 | 文本 |
| --- | --- |
| 0 | `WMRLearnSts_Unlearnd` |
| 1 | `WMRLearnSts_LearningRP0RF0` |
| 2 | `WMRLearnSts_LearningRP1RF0` |
| 3 | `WMRLearnSts_LearndRP1RF0` |
| 4 | `WMRLearnSts_LearndRP1RF1` |
`gVR_WinOpenReq[0]`(`valCount=32`,注释 `Enumerated data type: WinOpenReqAtDrvr`):
| 值 | 文本 | 值 | 文本 |
| --- | --- | --- | --- |
| 0 | `Idle` | 16 | `Open56` |
| 1 | `CloseFull` | 17 | `Open60` |
| 2 | `OpenFull` | 18 | `Open64` |
| 3 | `Open4` | 19 | `Open68` |
| 4 | `Open8` | 20 | `Open72` |
| 5 | `Open12` | 21 | `Open76` |
| 6 | `Open16` | 22 | `Open80` |
| 7 | `Open20` | 23 | `Open84` |
| 8 | `Open24` | 24 | `Open88` |
| 9 | `Open28` | 25 | `Open92` |
| 10 | `Open32` | 26 | `Open96` |
| 11 | `Open36` | 27 | `STOP` |
| 12 | `Open40` | 28 | `VentnReq` |
| 13 | `Open44` | 29 | `open` |
| 14 | `Open48` | 30 | `Cls` |
| 15 | `Open52` | 31 | `Resd` |
(表中文本的前缀 `WinOpenReqAtDrvr_` 已省略;`gVR_WinOpenReq[1]` 的 CC 内容完全相同。)
⚠️ 目前 `ParseChannel` 只认 `cc_type ∈ {0,1}`,所以这两个通道在界面上显示为原始整数;
要显示标签需要按 `cc_type == 7` 读 `links[4..4+valCount-1]` 并与 `cc_val[]` 一一对应,
再随信号传递到界面(本轮按要求**未做代码改动**)。
---
## 本轮(2026-10-20,本机):MF4 `##SD`/`##SI`/VLSD + 枚举文本 + `.mat` 改为随机读
> 用户需求:① 修复 §2.6 的两个已知问题;② 为信号增加**枚举信息**(准确命名 `enumText`,不是
> 单位),以 `TestData/NEW-MF4.mf4` 为目标;常规状态显示不变,**只有启用光标时**在数值后追加
> ` = <枚举文本>`(等号两侧各一个空格、无尖括号);③ MF4 支持 `##SD`/`##SI`、VLSD、
> `cg_flags` 主通道判定、进度分母;④ `.mat` 由内存映射改为「随机读 + 元数据块缓存」,
> 流式读取并动态更新「已读/总大小」,在可能范围内加速;⑤ 离屏验证后提交,不占用鼠标键盘屏幕。
>
> 验证(全部离屏,无可见窗口、无注入输入):
> * 新增 `.preflight/perf/loadercheck.exe`(数据层)**32/32**;
> * 新增 `.preflight/perf/enumui.exe`(真实 `Application` + `SW_HIDE` + 光标标签宽度)**12/12**;
> * 回归 `datacheck` 69/69、`matcheck` 21/21、`matui` 15/15、`verify` 49/49;
> * `make release` / `make debug` 零警告;`Target.exe` 785,920 → **820,224 字节**(+34KB)。
### A. §2.6 两个已知问题
1. **`LoadPlainObject` 解码两遍**:改为先 `ReadDatasetInfo`(只取 `dims`)再 `ReadNumbers`,
不再为了维度去调用 `ReadDataset`(那会把整块数据解码第二次)。
2. **`MdfFile::TextBlock` 注释与实现不符**:现在真的支持 `##MD`(数据段是 UTF-8 XML,
没有长度前缀 ⇒ 直接转宽字符串)。`##TX` 的两种写法(长度前缀 / CANape 裸文本)保持不变。
### B. 枚举信息(`enumText`)
- 数据层新增 `struct SignalEnumText { values[]; texts[]; Empty(); Find(value); }`
(定义在 `DataTable.hpp`)。**命名刻意用 `enumText`**:它描述「取值代表哪个状态」,
不是单位、不是注释。`DataTable::columnEnumText` 与 `columns` 同下标;
`SignalData::enumText` 由 `BuildSignalGroup` 移动赋值。
- MF4 侧:`cc_type = 7` 时按 `cc_val_count` 读 `cc_val[]`(F64,从数据段 +24 开始)与
`cc_ref[4 + i]`(`##TX`/`##MD` 文本);最后一个引用是「默认文本」,不使用。
文本为空的取值不进入枚举表(保持 `values/texts` 对齐)。
- 界面:**只有光标取值标注**追加 ` = <枚举文本>`(`PlotViewPaint.cpp` 里 `text += L" = "`)。
常规状态(未启用光标)一个像素都不变;枚举查表用**采样原值**(在 `ApplyDrawTransform` 之前),
避免系数/镜像把状态查丢。
- `TestData/NEW-MF4.mf4` 实测:8 信号,4 条带枚举
(`gVR_WinOpenReq[0/1]` 32 项、`gVP_WindowLearnSt[0/1]` 5 项);`MF4-Record.MF4` 也有 4 条
(`gVP_ActrStopReazon[0]` 12 项、`gV_WinPosnSts[0]` 32 项)。
### C. MF4:`##SD` / `##SI` / VLSD / 主通道 / 进度
- **`##SI`(采样索引)**:只有当索引表 **≥2 条且全部 < 周期数**时才生效(逐记录有效性位图,
其余行保持 NaN)。⚠️ 实测 `MF4-Record.MF4` / `NEW-MF4.mf4` 里每个通道都挂了一个只含
**1 个值(513 / 4)** 的 `##SI`——那显然不是采样下标,必须忽略,否则整列会被清成 NaN。
- **`##SD` + VLSD(`cn_type == 1`)**:`##SD` 数据段是「`UINT32` 长度 + 字节」的样本序列;
记录在该通道 `cn_byte_offset` 处放 **64 位偏移**。实现 `AppendSignalData`(递归展开
`##SD`/`##DZ`/`##DL`/`##HL`,单通道上限 512MB)与 `DecodeVlsdValue`
(直接偏移优先,失败再按 `>>1` 回退;取样本的第一个元素作为采样值)。
- **`cn_data` / `cn_md_unit` / `cn_md_comment` 的链接下标按链接总数反推**:
8 链接(MDF 4.0 风格)= 换算[4]/数据[5]/单位[6]/注释[7];9 链接(4.1)= [5]/[6]/[7]/[8]。
⚠️ 实测 Simulink 导出的「4.10」文件仍写 8 链接 ⇒ 不能写死 `Link(cn, 4/5)`。
- **主通道判定**:`cn_sync_type` = 1/2/3 → 名字(`t`/`time`/`timestamp`/`zeit`/`时间`,
含 `t[...]`)→ 第一个数值通道兜底。`cg_flags` 第 2 位(远程主通道)时跳过 sync 判定。
- **记录长度**:优先 `cg_data_bytes`(CG 数据段 +24),其次「载荷 ÷ 周期数」,最后按位宽推算。
⚠️ 旧实现把 `U8(cgData)` 当 `cg_record_id_len`,那其实是 `cg_flags` 的低字节(总线事件位)。
- **进度**:先走一遍 DG 链得到**真实组数**(不再写死 64),`hasBytes = true`,
`bytesRead` 来自 `MdfFile::ReadAt` 的累计(上报时夹到文件大小);4MB 分块读取时检查取消。
- **`##DZ` 修正**:数据段前面有 28 字节头(`original_type`/`zip_type`/`param`/
`original_size`/`zip_size`),旧实现把整段当 zlib 流是错的。只支持纯 deflate(`zip_type = 0`)。
- 失败诊断:没有任何可绘制通道时,错误信息附带「布局无法解析 / 缺少时间通道 / 空数据组」计数。
### D. `.mat`:内存映射 → 随机读 + 元数据缓存
- `MatHdf5::Open` 改为 `CreateFileW(FILE_FLAG_SEQUENTIAL_SCAN)` + `ReadFile` 随机读;
`MappedBytes` 删除,改为 `ReadAt` / `Cached` / `Tag` / `ReadData`。
**彻底消除 SEH 风险**(I/O 失败只会变成 `false`)。
- 元数据:16 × 64KB 块缓存(LRU + 上次命中槽快路径),覆盖对象头 / B-tree / 本地堆;
成员名用 `ReadCString`(跨块边界安全)。
- 数据:连续布局按 4MB 切片;分块布局**按需读取 + 64KB 对齐**,
⚠️ 固定 4MB 预读在交错存放的分块数据集上会把读盘量放大到文件大小的 2.2 倍
(实测 2874MB / 1293MB)⇒ 改后 1301MB。
- **关键提速**:`dims=[N]`、chunk `[32768,1]` 归一化后 rank=2、每行 1 字节,
逐行算坐标是 **12.9 亿次迭代**(约占总时间一半以上)。新增
「rank == 2 且 `chunkDims[1] == dims[1]`」快路径:整块一次 `sink`。
实测 `BIG_MAT.mat`(1.29GB / 5 信号 × 16,142,471):**21.5s → 7.9s**,私有内存 **+741MB**
(最终列数据 739MB,与映射版一致)。
- **进度**:`Open` 立刻上报「0 / 总大小」;之后 `ReadData`/切片每读一块回调
`ReadProgress(bytesRead, total)` → `MatLoader` 映射到 0.02–0.98,并进行**单调夹紧**
(字节进度与逐流解码进度取较大值),收尾 1.0。遮罩层百分比不再「一上来就 100%」。
取消会置 `m_readCancelled` 并让 `StreamDataset` 立即失败(不会留下 NaN 空洞)。
- 连续布局数据不足(文件被截断)现在**明确失败**,不再静默截短。
---
## 本轮(2026-10-21,本机):MF4 换算类型 2/3/4/5/6 + `##CA` 数组通道 + 数值型 VLSD
> 用户要求:对标定量(特性曲线 / 公式)与 `##CA` 数组通道补全支持,VLSD 只做数值型;
> 完成后**列举 MF4 支持情况**、说明文件里的 VLSD 内容、报告单位相关发现。
> 全程离屏(`.preflight` 合成文件 + 数据层断言),不占用用户的鼠标 / 键盘 / 屏幕。
### A. 换算:`linearConversion/offset/factor` → 类型 + 参数表
- `ChannelSpec` 原先只有线性的三件套,2–6 号换算根本没位置放参数 ⇒ 换成
`ConversionType conversion`(`NONE/LINEAR/RATIONAL/ALGEBRAIC/TAB_INTP/TAB/TAB_RANGE/
VALUE_TO_TEXT/VALUE_RANGE_TO_TEXT`)+ `std::vector<double> conversionValues`。
- 新增 `ApplyConversion(spec, raw)`,`ParseChannel` 按 `cc_type` 校验参数个数后填 `conversionValues`:
- `cc_type = 1` 线性 `v0 + v1·raw`(`v0 == v1 == 0` 视为未配置 ⇒ 恒等);
- `cc_type = 2` 有理式 `(v0 + v1·raw) / (v2 + v3·raw)`;
- `cc_type = 3` 代数式 `(v0·raw² + v1·raw + v2) / (v3·raw² + v4·raw + v5)`;
- `cc_type = 4` 表插值:`cc_val[]` 是**交错**的 `(键, 值)` 对,raw 落在两点之间时线性插值,
区间外夹紧到端点值;
- `cc_type = 5` 表阶梯:同一张表,取「键 ≤ raw 的最后一段」的值(不插值);
- `cc_type = 6` 区间映射:`(下限, 上限, 物理值)` 三元组,命中哪个区间就用哪个物理值。
- ⚠️ **安全兜底**:参数个数不足 / 表为空 / 分母为 0 ⇒ **返回原始值**。
用户看到未标定值可以自己判断,看到编造值会误判数据。A/B 已证这些 fallback 不影响正常路径。
### B. `##CA` 数组通道(一个通道装整条曲线)
- 真实文件里只有 `MF4-Record.MF4` 带 `##CA`:25 个通道,维度 `[9]` / `[7]` / `[7,9]` / `[2]`,
全部 `ca_storage == 0`、`dgCycles == 1`、CN `flags = 0x14`。
- 字节布局(hexdump 实测确认):`+0 ca_type`、`+1 ca_storage`、`+2 ndim(U16)`、`+4 flags(U32)`、
**`+16 dim_size[]`(U64,步长 8)**,维度表之后数据段就结束。
- `ExpandArrayChannel(ChannelSpec&&, channels)`:仅当 `ca_storage == 0`、`1 < 元素数 ≤ 1024`、
`bit_count != 0` 时展开 —— 每个元素一列,`bit_offset += i * bit_count`,
名字 `name[i]`(1 维)/ `name[i][j]`(2 维),换算与枚举**复制**给每个元素。
- 效果:`MF4-Record.MF4` 信号数 **124 → 283**;9 点特性曲线读到 6000…14000,
二维 map 读到 175/120/180…(与文件里的 `Win_CAL_*` 标定表目视一致)。
### C. VLSD:数值型完整支持,非数值型仍不支持
- 旧代码 `numeric = IsNumericType(dataType) && bitCount > 0 && bitCount <= 64` 把
`cn_bit_count == 0` 的 VLSD 通道整列丢掉 ⇒ 改为:VLSD 只要求「数值类型」。
- 新增 `InferVlsdElementBits(type, length)`:按样本长度整除优先 64,再 32/16/8;
`DecodeVlsdValue` 不再要求位宽非 0,越界检查只做**一个元素**,多元素样本取第一个元素。
- `SelectMasterChannel` 增加 `!vlsd` 条件(VLSD 记录里存的是 `##SD` 偏移,当时间轴会得到垃圾值)。
- ⚠️ 实测 4 个真实 MF4 文件里 **VLSD 通道各只有 1 个,且都是 `Comment` 字符串**(`cycles = 0`),
**数值型 VLSD 数量为 0** ⇒ 本轮改动不改变任何现有文件的输出(只是补上了能力)。
### D. 单位:文件里几乎没有
- 4 个真实文件的 `cn_md_unit` **全部是空链接**(非空单位的通道数 = 0),所以现在没有单位可显示。
- 单位信息只以**文本**形式出现在两处:
1. `cn_md_comment` 的 `<TX>` 里,例如 `车窗堵转电流阈值电机驱动电源断点(单位:MV )`、
`车窗电流堵转阈值温度断点(单位:0.1*N-700℃)`、`1号车窗电机上堵转电流阈值(单位:0.1A )`;
2. 同块 XML 里的 CANape `<display>` 串:`CANape: 65280 2 0 11 -55.2808 75.5730 0 3 -2 1 3`
(含显示区间 / 小数位等)。
- 结论:**读单位要用 `cn_md_unit`,但当前样本文件里它是空的**;若要显示单位,只能退而从
`<TX>` 里正则抽取(脆弱)或解析 CANape `<display>`(格式未公开)。建议等拿到带单位的样本再定。
### E. 验证
- 新增 `.preflight/convcheck`:**现场合成**一个最小 MDF4(64 字节 ID + `##HD` + 1 DG / 1 CG /
2 条记录 / `recordBytes = 72`),一次覆盖 cc_type 1–6、`##CA`(4 元素、`storage = 0`)、
数值 VLSD(`bit_count = 0` + `##SD` 里两个 double)⇒ **11/11 通过**。
- **A/B**:把 2–6 的 `spec.conversion` 强置 `NONE` 并把数组展开上限改成 0 ⇒ **8 项如实失败**
(5 个换算 + 3 个数组断言),还原后 11/11,证明断言不是自证。
- 回归:`check` 137/137、`editcheck` 20/20、`round3check` 31/31、`round4check` 34/34;
真实文件信号数 `MF4-Record` 283 / `Problem-MF4` 8 / `左后门意外下降` 18 / `按前门开关,后门动了` 24。
- `make release` / `make debug` **零警告**;`TargetCheck.exe` **804,352 字节**(785.5 KiB,仍在 800KiB 以内)。
- ⚠️ 复测探针时**不要覆盖正在运行的 `Target.exe`**:统一用 `make release TARGET_FILE_NAME=TargetCheck`。
---
## 本轮(2026-10-20 第五轮,本机):`##CA` 全字段 + 3 维命名 + `cc_type = 8` 区间枚举 + 包络带线宽双向加粗
> 用户要求:① 处理前面报告里的「护栏改成按单元格数」「读 `ca_type`」以及「真正的待办」三条
> (`ca_type` / `ca_cycle_count` / `ca_byte_offset_base`)和「ndim 支持三维」;② 若范围枚举文本映射
> (`cc_type = 8`)没实现就补上,且同样只在光标模式下显示;③ 修「改变线宽但图像上纵向的绘制线宽不变」;
> ④ 先回答「6 位有效数字」到底指什么;⑤ 完成后列举 MF4 特性与支持程度并提交。
> 全程离屏(合成文件 + 数据层断言 + 离屏像素探针),不占用用户的鼠标 / 键盘 / 屏幕。
### A. `##CA`:从「按魔数猜」改成「按记录容量校验」
- **三个待办字段的结论**(都用 `.preflight/mf4catype.ps1` 扫过 4 个真实文件):
- `ca_type`:**实测 0 / 1 / 2 都存在**,但三者**物理布局完全相同**(元素在记录内按 `cn_bit_count`
连续排布,`cg_data_bytes` 恰好等于 `头 + 元素数 × 位宽`)⇒ 语义不同、存储同构,
因此 0/1/2 **都展开**,`> 2`(未知)才不展开。
- `ca_cycle_count`:**实测不存在**(`##CA` 块长度到 `dim_size[]` 就结束)。盲读会拿到
下一个块头当数字(实测 1481909027)⇒ 必须先按块长度判断「读得到」才读。
真读到时只要任一维 `> 1`(元素被拆到多条记录),就**不展开**。
- `ca_byte_offset_base`:实测值 2(= uint16 的字节宽度,巧合),**不用于定位**;
只记录进 `ChannelSpec` 供诊断(定位仍用 `cn_byte_offset`,已被真实数据验证)。
- **展开条件重写**(`ExpandArrayChannel(spec, cycles, recordBytes, out)`):
`ca_storage == 0`、`ca_type ≤ 2`、`bit_count ∈ [1,64]`、`cycle_count 每维 ≤ 1`、
**`bit_offset + 元素数 × 位宽 ≤ recordBytes × 8`**(真正的完整性校验)、
元素数 ≤ 16384、`元素数 × 记录数 ≤ 32M`。
旧实现的 `count > 1024` 魔数与「`bitCount == 0` 就放弃」被上面这些**有依据**的判据取代。
- **3 维命名**:按 `dim_size[]` 从末维向前取余,1/2/3 维分别得到 `[i]` / `[i][j]` / `[i][j][k]`。
- 真实文件回归:`MF4-Record.MF4` 仍是 **283** 个信号(25 个 `##CA` 全部通过新的容量校验,
最大 7×9=63),其余三个文件 8 / 18 / 24 不变。
### B. `cc_type = 8`(值区间 → 文本):补齐
- `SignalEnumText` 新增 `rangeMin / rangeMax / rangeTexts`,并拆出 `HasExact()` / `HasRanges()`:
`Empty()` = 两者都无效;`Find(value)` **先精确表、后区间表**(闭区间);`Count()` 供诊断。
- `ParseChannel` 新增 `VALUE_RANGE_TO_TEXT` 分支:`cc_val[]` 交错 `(下限, 上限)`,
`cc_ref[4 + i]` 是第 i 段文本(**最后一个引用是默认文本,不使用**);
`cc_val_count` 必须为偶数(奇数 = 表残缺 ⇒ 整表丢弃);`下限 > 上限` 时交换;非有限值跳过。
- 界面**没有改动**:`cc_type = 7` 与 `8` 共用同一个出口(光标取值标注追加 ` = <文本>`),
因此「只在光标模式下显示」这条要求自动成立,常规状态零变化。
### C. 线宽:包络带必须纵横双向加粗(用户报告的缺陷)
- **根因(代码级确认)**:`bandColumn.top = yTop - strokeHalf; bottom = yBottom + strokeHalf;`
只在**纵向**外扩。包络带每列只有 1 个 x 坐标,填充多边形的横向跨度就是「1 个像素列」⇒
**方波的竖直沿(上升/下降段)横向宽度恒为 1px**,与线宽无关。用户看到的现象正是
「水平段变粗了,竖直方向的沿一点没变」。
⚠️ 不是缓存问题:`lineWidth` 早在 2026-10-16 就进了 `PolylineCacheEntry` 的键。
- **修法**:`EmitEnvelopeBand(..., float halfWidth)`:
1. **横向**:对上下包络做**窗口膨胀**(`top` 取窗口最小、`bottom` 取窗口最大,
半径 `ceil(halfWidth / 列距)`)——等价于用边长 `2*halfWidth` 的方块做闵可夫斯基膨胀,
竖直沿因此变成 `2*halfWidth + 列宽` 宽;
2. **纵向**:再各外扩 `halfWidth`(水平段厚度);
3. 膨胀**只在同一段内**(`segmentColumns` 按段清空),不会跨 NaN 断点连线。
调用方不再自己给列加 `strokeHalf`(列里存**精确**包络)。
### D. 关于「6 位有效数字」
- `FormatValue`(`Utilities/Src/Utf8Util.cpp`)用 `%.6g`:是**总共 6 位有效数字**,
**不是**小数点后 6 位。`123.456789 → 123.457`;`123.456789 ± 5e-7` 的两个值格式化后**完全相同**
(波动在第 9 位有效数字上);`%.6g` 在指数 < -4 或 ≥ 6 时切成科学计数法。
- 这只是**显示格式**,数据本身是完整 double(绘图、边沿统计、派生信号都用原值)。
若要观察微小波动需要改显示策略(本轮按用户要求**未改**)。
### E. 验证
- `.preflight/convcheck`(合成 MDF4,recordBytes 72 → 128):**18/18**
- 新增:`##CA` 3 维 → 8 个 `caCube[i][j][k]` 且 `[1][1][1]` 取值 8/18;
故意放到 `byteOffset = 124` 的 4 元素数组(需要 1056 位 > 1024 位)**必须不展开**;
`cc_type = 8` 两段区间 → `5.0 → LOW`、`15.0 → MID`、`25.0 → 无文本`。
- 新增 `.preflight/round5check`(离屏像素探针):合成 20 万采样「平坦 + 短脉冲」数字信号
⇒ 必然走稠密包络带;在网格中间行统计**接近信号色**的像素数(= 竖直沿横向宽度):
**1px → 6px、10px → 30px**,**5/5 通过**。
- ⚠️ 像素统计**不能用「隐藏信号的参考帧」做差**:隐掉唯一信号会改变网格幅值范围,
网格线与 Y 轴刻度跟着变,差分会把网格也算成波形(实测虚高到 57px)。
- **A/B**:临时令膨胀半径 `radius = 0`(只保留纵向外扩 = 旧行为)⇒ `1px → 2px、10px → 2px`
且「thickens the vertical stroke」如实 FAIL;恢复后 5/5。
- 回归:`check` 137/137、`editcheck` 20/20、`round3check` 31/31、`round4check` 34/34;
真实 MF4 信号数 283 / 8 / 18 / 24;枚举表仍解析出 32 / 12 / 5 / 32 项。
- `make release` / `make debug` 零警告;`TargetCheck.exe` **808,448 字节**(789.5 KiB,仍 < 800KB)。
---
## 本轮(2026-10-20 第六轮,本机):全项目大检查(性能 / BUG / 过期注释 / 命名)+ 体积评估
> 用户要求:对全项目做一次大检查(性能、BUG、错误、过期注释、函数/参数命名与实际含义不符),
> 修复存在的问题;然后**评估**能否进一步压缩二进制体积(但**先不要**往压缩方向改);完成后提交。
> 方法:四个只读子审查(`Utilities` / `PlotView` / `SignalPanel+TabBar+Toolbar` / `Application+Shared`)
> 交叉核对,每条结论都回到源码逐行确认后才动手。
### A. 数据层(`Utilities`)修复
| 位置 | 问题 | 修法 |
| --- | --- | --- |
| `MatH5::U8/U16/U32/U64` | 逐字节 `Cached()` 拼装,而 `Cached` 只服务同一 64KB 块内请求 ⇒ **跨块标量静默为 0**(分块 B-tree 地址被破坏,无报错) | 新增 `ScalarLE()`:命中缓存用缓存,否则一次 `ReadAt`;`U64` 从 8 次查找降到 1 次 |
| `Mf4Loader::IsBlock` | 未校验链接表落在块内 ⇒ `DataOffset` 越界,`block_len-(data-offset)` **uint64 下溢** ⇒ `vector(2^64)` 崩溃 | 校验 `24 + 8*links <= block_len`;`ReadAt`/`IsBlock` 的边界判断改为不产生回绕的写法 |
| `DecodeRawValue` 整数路径 | 非字节对齐的 **64 位**整数需要 9 字节,先拼 64 位再右移会丢掉最高字节 ⇒ 数值错误 | 改为逐字节**带位偏移累加** |
| `cg_cycle_count` | 完全信任文件里的记录数(损坏文件可申请几十 GB) | 用「数据段总字节 / 记录字节」做上限,压缩段按 deflate 最大膨胀比估算 |
| `CsvLoader` 表头 | 不跳过 `#`/`%` 注释行 ⇒ 整份文件按 1 列解析并丢弃 | 表头分支同样跳过注释行 |
| `MatLoader` | `static_cast<int>(double)`(NaN / 越界 = UB);结构体递归无深度上限 | 新增 `StreamIndexFromDouble()`(非法返回 -1);递归加深度 32 + 已访问集合 |
| `Utf8Util::BuildTicks` | `double → long long` 越界转换(`1e300/1e-300` 溢出为 inf) | 先判商落在 ±9e18 内,否则放弃画刻度 |
### B. PlotView 修复
- **`FindStyleOnlyLanes` 只比 4 个字段**(color/opacity/lineWidth/mode),而 `SceneGeometryHash`
还排除了系数/绝对值/镜像/偏移 ⇒ 两个网格同时改不同属性时只有一个被登记为脏,
另一个带着旧像素被当成“最新”。改为 `!(now[index] == before[index])`(`SceneStyleEntry::operator==`)。
- **拖拽光晕被烘进宽基底图层**:`PaintLane` 画光晕时没有 `!m_renderWide` 守卫,而 `LaneLayerHash`
不含光晕状态 ⇒ 拖拽期间重建一次图层就把半透明光晕永久烘进去。图层内跳过光晕,
改由 `PaintHoverHighlight` **每帧叠加绘制**(它也扩展为“拖拽中画被拖信号的光晕”)。
- **`m_legendHits` 半可见残留**:`PaintLaneLegend` 在“图例带不完全可见”时提前 return,
上一帧的命中矩形留在原位 ⇒ 能点中**看不见的**图例条目。提前返回前先清掉本网格的命中项。
- **面板主体 96% → 完全不透明**(`0xF4` → `cardBg`):面板像素图层被缓存复用,而
`PanelGlobalKey` 刻意不含视界/幅值(否则平移、竖向拖拽每帧重画全部面板)⇒ 那 4% 透出的
“面板背后的场景”会一直停在**平移前**的波形。改为不透明后取舍消失且零代价。
- **PAN / Shift+滚轮改走 `SetTimeView`**(视界唯一写入口):否则「自动点线」判据在平移时
永远不重算(从密集区平移到稀疏区也不切换)。
- **内联输入框命中字体**:绘制用 `monoSmall`,命中用 `uiSmall` ⇒ 插入符位置偏移。统一 `monoSmall`。
- **派生曲线取值未做镜像映射**:曲线按 `values` 的**源下标顺序**计算,而光标给的是显示下标 ⇒
水平镜像时读到镜像另一端。窗口与取值都改为 `SourceIndexFor` 后的源下标。
- **`EdgePanelWidth` 缺 `style/axis` 空指针守卫**(它被绘制、命中、`QueryCursor` 调用)。
- **二分结果未夹到 `count`**(`MinorCellSparse` / `AutoRangeLaneToView`):分组时间轴比信号长时越界读。
### C. 界面(SignalPanel / Toolbar / Application / Shared)修复
- **工具栏按钮永远不高亮**:`Paint` 读 `Button::hover`,该字段全仓从未赋值。改为按 `m_hoverIndex` 判断。
- **菜单自己的内联输入框无法拖拽框选**:菜单打开时“取消底层拖拽”的代码对**每个消息**都执行,
把菜单的 `Target::EDIT_FIELD` 也清掉了(并释放了捕获)。排除 `EDIT_FIELD`。
- **退出时 GDI+ 访问违例**:`GdiplusShutdown` 前未释放 `PlotView` 的位图(单例析构在 `WinMain` 之后)。
新增 `PlotView::ReleaseGraphicsResources()` 并在 `Shutdown()` 里调用。
- **`WM_KILLFOCUS` 不提交编辑**:标签页重命名与绘图区内联输入停留在编辑态(Alt+Tab 回来继续打字)。
改为提交 + 复位捕获/拖拽状态。
- **`WM_LBUTTONUP` 无条件释放捕获**会抢走中键平移的捕获 ⇒ 平移“卡住”。中键平移期间不释放。
- **`TextEdit`**:被过滤器拒绝的字符曾返回 `true`(调用方以为内容已变);**无选区 `Ctrl+X` 会清空整段文本**
(用户想复制阈值却把值删了)⇒ 改为只复制、不清空。
- **`SignalPanel::InvalidateRows()` 不作废行下标** ⇒ 行集合在编辑中变化后回车会把名字写到别的信号上。
- **`EndFilterEdit()` 不调 `ApplyFilterText()`** ⇒ 输入纯空格后回车,列表仍停在筛选结果。
- **`SignalBadgeVisible()` 成为唯一判据**(原来命中只看“可见”、绘制还要求“已分配网格”)。
- **折起动画结束后夹取滚动量**(`RefreshScrollBar(animationFinished)`):否则列表底部空白、滑块画出轨道。
- **快速双击分组名改名后折起动画仍在播**:进入改名时清掉该组的动画条目。
- **`ScrollToSignal` 复用 `SignalScrollTarget`**(含悬浮组名避让):原来切标签页后选中项被悬浮组名遮住。
- **`RowNameRect` 与绘制使用同一最小宽度**(原来退化时返回空矩形,名字画得出来但双击得到 0 宽输入框)。
- **分隔条拖拽上限改用与 `Relayout()` 同一公式**(原来恒等于 `kMaxPanelWidth`,窄窗口下宽度回弹)。
- **`UpdateDragCursor` 补 SPLITTER/EDIT_FIELD**(拖分隔条显示 `IDC_SIZEWE`、输入框显示 `IDC_IBEAM`)。
- **`ApplyWidthFromX`/`ApplyOpacityFromX`/`ApplyColor`/`BeginEdit` 的守卫改为判断真正解引用的指针**
(原来判 `m_tab`,实际用的是 `m_dataSet(Writable)`)。
- **分组行 ✕ 也返回手型光标**(与信号行 ✕ 视觉一致);`ScrollThumbRect` 的进度夹到 [0,1]。
### D. 清理(死代码 / 死机制 / 过期注释)
- 删除:`Toolbar::ActionLabel`(无调用)、`Theme::statusBg`(状态栏早已删除、无读取)、
`PopupMenu` 的 `editResult` 保存/还原(永远原样返回空结果)、`Toolbar::Layout` 的重复 `clear()`、
`SignalPanel::HitRow` 的恒假分支、`PlotView::OnDoubleClick` 的重复命中判定、
`PaintUtil.cpp` 里孤立的注释块。
- 修正过期/错误注释:`EmitEnvelopeBand` 的“不做任何列窗口加宽”(现按线宽横向膨胀)、
`LaneLayerHash` 的“图例文案计入”(实际未计入)、`PaintMeanFilterCurve` 的“只取可见区间±保护带”
(实际按整条计算)、样式区“固定四行/中心反转/0=系数调整”、`FIT_ALL` 的“同时退出缩放模式”、
`Application::RemoveSignal` 的“行左侧 ✕”、`.mat`(`LoadSignalSource` 不处理)与 `.dat`(漏写)、
`MatH5` 的“映射视图”、`DataModel` 的“点线/被移除文件”、`SignalPanelPaint` 的“行高会连续缩小”
与“文件内的列名”、`TextEdit.hpp` 的 `PaintUtil::UiDrawEditField`(已迁到 `UiControls`)、
`Mf4Loader` 的 `syncMaster`(`>= 1` → `1..3`)。
### E. 二进制体积评估(按要求**只评估、未做压缩修改**)
| 项 | 数据 |
| --- | --- |
| release 产物 | **809,472 B(790.5 KiB)**,比上一轮 +1,024 B(新增守卫/`ScalarLE`/`ReleaseGraphicsResources`) |
| `.text` | 652,336 B(80%)— 其中各 `.o` 的 `.text` 合计仅 **587,672 B**,其余是静态 CRT / libstdc++ / 异常与线程支持 |
| `.xdata` + `.pdata` | 43,088 + 31,632 = **~75 KB(9%)**(SEH 展开信息;`-fno-asynchronous-unwind-tables` 在本工具链**链接失败**,`-fno-exceptions` 会破坏 `bad_alloc` 捕获 ⇒ 不建议动) |
| `.rdata` | 42,752 B(字符串字面量 / 常量表) |
| `.rsrc` | 20,656 B(图标已全部转 PNG) |
| `.idata` | 11,500 B |
| 去掉 `-ld3d11 -ldwmapi -ld3dcompiler`(仓内无任何调用) | **实测 0 字节收益**(静态库只拉取被引用的对象),所以“删链接库”不是压缩手段,只是配置整洁 |
| 仓内重型头文件 | 已无 `<iostream>/<sstream>/<filesystem>/<regex>/<locale>`(唯一历史炸弹 `<filesystem>` 已被移除) |
**结论**:已经没有“明显的浪费”可捡(`-Oz` 已在用;`--gc-sections` 实测变大;LTO 在本工具链 ICE;
未使用的链接库零收益)。要再显著变小只能改变**取舍**,都需要用户决策:
① 动态链接 UCRT/libstdc++(可省掉大部分 CRT 代码,但失去“单文件绿色运行”);
② 上 UPX 之类的可执行压缩(体积约减半,代价是启动解压 + 杀软误报风险);
③ 削减功能(例如去掉并行图层线程池、去掉内置 inflate 改用系统 zlib DLL)。
### F. 验证
- 离屏探针:`editcheck` 20/20、`round3check` 31/31、`round4check` 34/34、`round5check` 5/5、
`convcheck` 18/18;`check` **136/137** —— 唯一失败项 `the plot is not corrupted by a panel-animated frame`
经 `git stash` 回到**本轮改动前**复现同样数值(`drift=15818`),确认是**该探针自身的既有不稳定项**,
与本轮改动无关(此前同一探针曾全绿,取决于前置用例留下的缓存/时序)。
- 真实数据回归:4 个 MF4 → 283 / 8 / 18 / 24 个信号;`.mat`:样例 4 组 8 信号、
`带无障碍对照-防夹力曲线数据 Group1 x2.mat` 4 组 8 信号、**`BIG_MAT.mat` 5 信号(私有内存 +741MB)**。
- `make release` / `make debug` **零警告**。
---
## 本轮(2026-10-20 第七轮,本机):实验性功能「自由标记」
> 用户需求:在网格右键菜单的「实验性功能」里新增带 √ 的「自由标记」。
> 开启后网格内跟随鼠标出现一条**仅限本网格**的纵向光标(上下各一个把手、不显示时间值),
> 光标穿过信号时只标注**离鼠标最近**的数据点;左键单击把该点固定下来(带 ✕ 可单独移除,
> 固定点在鼠标移开后仍显示、超出视界直接不显示),后续固定点显示为相对前一个的增量;
> 浮动面板按信号分组列出 `值(±增量) = 枚举文本` + `t:… Δt:…`(两列右对齐,可双击按秒编辑,
> 编辑后位置移动并在越过后续点时自动换位);命中「自动点线」判据的信号要给出吸附提示;
> 面板两级 ✕(信号名清空该信号 / 行尾移除单项)都必须有悬停提示;
> 与「选区放大」共存时**原地单击 = 固定数据点、按下后拖动 = 框选**。
### A. 数据层(`Utilities`)
- `Lane` 新增:`bool freeMarker`、`std::vector<FreeMarkerItem> freeMarkerItems`、`LanePanelState freeMarkerPanel`。
`FreeMarkerItem{ SignalRef ref; double time; }` —— **`time` 是显示时刻**(= 画在 `TimeToX(time)` 处),
与 `TabConfig::CursorTime()` 同一口径;取值用 `SampleDisplayedSignalAt`(内部处理时间偏移与水平镜像)。
- 新增 `AddFreeMarker / RemoveFreeMarker / RemoveFreeMarkerAt / RemoveFreeMarkersOf / FindFreeMarker /
HasFreeMarkers`。同一信号同一时刻**不重复加入**(相对容差 1e-12);`freeMarkerItems` **保持插入顺序**
(删除某项不打乱其它项的下标),显示顺序一律由 `SortedFreeMarkerIndices` 按时间排序得出。
- `LaneCarry` 同步携带三件套 ⇒ 勾选/取消勾选信号导致网格重建时**不会丢掉已固定的点**。
### B. 网格内:跟随光标 + 固定点
- `ProbeFreeMarker(mouseX, mouseY)`:仅当鼠标所在网格开启了该功能时有效;在网格内**全部可见信号**里
挑屏幕纵向距离鼠标最近的那个数据点。判据 `MinorCellSparse` 成立时经 `SnapTimeToSample` **吸附**到
真实采样时刻(并置 `snapped`,绘制侧画一圈圆环作为吸附提示)。
⚠️ 吸附与否**只看判据**,不管当前是散点/线段、也不管用户是否手动改过样式(用户明确要求)。
- `PaintFreeMarker`(在 `paintOverlays` 里、`PaintEdgeCursor` **之前**调用):
* 跟随光标:纵向线**只在本网格内**(`clipTop..clipBottom`),上把手尖端朝下(与常规光标顶部把手一致)、
**下把手尖端朝上**(用户明确要求),**不画时间读数**;只画最近点的取值胶囊;
* 已固定点:低透明度细线 + 圆点 + `值(±增量) = 枚举` 胶囊 + 行尾 ✕(悬停高亮 + 提示气泡);
* **超出视界直接不显示**(不复用常规数据点的"贴边定格 + 方向三角")。
- 两处 ✕ 的命中:网格内固定点 ✕ 的矩形**由绘制时登记**(`m_freeMarkerHits`,与 `m_legendHits` 同套路),
因为它的位置取决于取值文本宽度;面板内 ✕ 直接取布局矩形。
- `OnMouseLeave` 清 `m_freeMarker` 与三处悬停态(已固定的点保留)。
### C. 单击 vs 框选(与「选区放大」共存)
`OnLButtonDown` 在绘图区内、开启该功能的网格上**只登记** `m_freeMarkerPendingFix`,
并把 `m_dragMode` 设为「选区放大模式 ? RUBBER_SELECT : PAN」;`OnLButtonUp` 里只有
**整段操作没有拖动且选区轴向未定**时才 `AddFreeMarker`,否则照旧走框选/平移。
⚠️ 不能在按下时就固定 —— 那样用户想框选时每按一次都会多一个固定点。
### D. 浮动面板(`EdgePanelKind::MARK`)
- 布局:标题「自由标记」;每个**可见信号**一行名称(右侧带「清除该信号全部固定点」✕),
名称行下方按 `SortedFreeMarkerIndices` 的顺序逐行列出该信号的固定点(从左到右 == 从上到下);
没有任何固定点时第一行给灰色提示「在网格内单击可固定数据点」。
- 行内容:左侧 `值(±增量) = 枚举文本`(**没有尖头括号**;首项无增量),右侧两列**各自右对齐**:
`t:<时刻>s`(与光标时间读数同格式)与 `Δt:+<复合单位>`(与双光标差值同格式,自动单位匹配),
再右侧是行尾 ✕。两列右边界存在 `EdgePanelLayout::markerTimeRight/markerDeltaRight`,
⇒ 所有条目的时间尾缀纵向对齐。
- 双击行:左半 → `PlotEditTarget::FREE_MARKER_TIME`(编辑自身时刻,秒);
右半 → `FREE_MARKER_DELTA`(编辑相对前一固定点的间隔,秒;最左边的点没有前一项 ⇒ 不进入编辑)。
提交后只改 `time`,顺序由排序函数自动重排(**越过后续点即交换条目位置**)。
- `PanelSlotKey` 把每个固定点的「信号 + 时刻」与全部 marker 行矩形、两列边界一起 mix
(否则增删固定点后会贴回旧像素)。
### E. 一并修掉的两个自伤
1. **`EdgePanelWidth` 的 MARK 分支原本逐行取采样值算文本宽度** —— 它被 `EdgePanelLayoutFor` 调用,
而后者在 `TopPanelAtPoint` / `SyncPanelOrder` 里**每次鼠标移动调用数次** ⇒ 变成"鼠标一动就几十次
采样查询"。改为固定宽度 `kFreeMarkerPanelWidth`(340 逻辑像素)+ 绘制侧 `UiEllipsize`。
2. `PaintFreeMarker` 与跟随光标的胶囊绘制原本是两份重复代码 ⇒ 抽 `PaintFreeMarkerCapsule` 共用;
`FreeMarkerLabelRects` 增加 `text` 出参,避免绘制侧重复求解一次取值。
### F. 验证
- 新增离屏 `.preflight/markcheck` **31/31**:
[A] 探测有效/就近选择随鼠标纵向变化;[B] 原地单击恰好新增 1 个固定点;
[C] 选区放大模式下原地单击 = 固定且不缩放、拖动 = 缩放且不固定;
[D] 面板行数 == 固定点数、组内按时间升序、首项无增量、后续项增量与 Δt 正确、两列右对齐;
[E] 行尾 ✕ 只删 1 项、信号名 ✕ 清空该信号全部;[F] 编辑 Δt 后该点移动到「前一点 + 间隔」并在越过后续点时变成最右项;
[G] 深度放大时 `snapped == true` 且时刻精确等于某个采样时刻;视界外的固定点 `FreeMarkerLabelRects` 返回 false。
- 回归:`editcheck` 20/20、`round3check` 31/31、`round4check` 34/34、`round5check` 5/5、
`convcheck` 18/18;`check` **136/137**(唯一失败项是本轮之前就已存在的不稳定项,上一轮已用 `git stash`
在基线复现同一数值);真实 MF4 仍 283/8/18/24。
- `make release` / `make debug` **零警告**;`TargetCheck.exe` **833,536 字节(814.0 KiB)**。
⚠️ **体积超出旧的 800 KiB 红线约 14 KB**(本轮 +24 KB)。已做的瘦身:面板宽度不测文本、
胶囊绘制去重。剩余体积是新功能的真实代码量(~800 行新代码 / ~30 B 每行)——
若必须回到 800 KiB 以内,需要用户决策(动态 CRT / UPX / 砍其它功能),本轮按"实现功能优先"未动。
---
## 本轮(2026-10-20 第八轮,本机):「自由标记」体验改进(默认无光标 + 四列对齐 + 光标模式按钮)
> 用户需求(逐条):
> ① **默认不启用**网格私有光标,只有鼠标处于信号上方时才显示数据点数值;命中「自动点线」稀疏判据的
> 信号要"只有靠近数据点时"才显示;② 有数值预览时左键单击固定该点(固定点样式不变:半透明纵向细线);
> 鼠标下方没有信号曲线时就是普通拖拽平移(选区放大模式下照旧框选);
> ③ 浮动面板**最下方**加一个切换按钮「启用光标模式」↔「关闭光标模式」,启用后与最初实现一致
> (纵向自由光标),但**必须修掉"光标模式下拖不动面板"**,且鼠标在面板上时网格不画光标、
> 面板上的操作不穿透到网格;④ 面板只列**有固定点**的信号(信号名条目否则不出现);
> ⑤ 每个信息元素**纵向对齐**(当前值 / 增量 / 当前时间 / 相对时间各自一列)。
### A. 数据层
- `Lane` 新增 `bool freeMarkerCursor = false`(**光标模式**开关,默认关闭)。
`freeMarker` 仍是功能总开关;`LaneCarry` 同步携带(增删信号导致网格重建时不丢状态)。
- `PlotView` 新增 `SetLaneFreeMarkerCursor / LaneFreeMarkerCursor / ToggleLaneFreeMarkerCursor`;
切换时若探针属于该网格就**立即作废**(否则关闭后仍残留纵向光标、开启后要等鼠标动一下)。
### B. 探针与绘制(默认模式 vs 光标模式)
- `FreeMarkerProbe` 新增 `cursor.cursorMode`。`ProbeFreeMarker`:
* **默认(非光标)**:同一信号若命中 `MinorCellSparse` 判据却**吸附失败** ⇒ 直接跳过
(两点之间不出预览);其余信号要求 `|y - mouseY| <= kFreeMarkerHoverTolerance`(8 逻辑像素);
一个都不满足 ⇒ `valid == false`(无预览 ⇒ 单击/拖动回到普通平移/框选)。
* **光标模式**:与原实现一致(网格内恒 `valid`,能取到点时才标注取值)。
- `PaintFreeMarker`:纵向线 + 上下把手**只在光标模式**绘制;取值胶囊 / 数据点 / 吸附圆环两种模式都画。
### C. 浮动面板
- 底部新增切换按钮(`layout.button`,高度 `kEdgePanelButtonHeight` + 上下 `pad`),
文案由 `lane.freeMarkerCursor` 决定(`kFreeMarkerCursorOnLabel` / `kFreeMarkerCursorOffLabel`),
手型光标 + 悬停高亮;`PanelSlotKey` 里 mix `freeMarkerCursor`(否则开关后贴回旧像素)。
- **只列有固定点的信号**:高度分支与矩形分支都用 `SortedFreeMarkerIndices(...).empty()` 跳过。
- **四列对齐**:`EdgePanelLayout::markerColumns[4]`(取值 / 增量 / 时刻 / 间隔)给定固定左边界与列宽
(`kFreeMarkerColumnWidths`,列间 `kFreeMarkerColumnGap`),面板宽度改为
`kFreeMarkerPanelWidth = 415`(= 4 列 + 间隙 + 行 ✕ + 两侧 pad,正好不多不少)。
绘制一律左对齐 + `UiEllipsize`;**不再**用"左文 + 右对齐两列"的混排。
- 双击分行改为按列边界判断(≥ Δt 列左边界 ⇒ 编辑间隔;否则编辑时刻),不再用 `xxxRight - 40*scale`。
### D. 修掉的 BUG(用户报告:"光标模式下拖不动浮动面板")
根因**不是**命中判定,而是 `OnMouseMove` 的 `case PlotDragMode::EDGE_PANEL_MOVE` 里
`switch (m_edgePanelDragKind)` **漏了 `MARK` 分支**:拖动时坐标被写进 `lane.edgePanel`,
而 `EdgePanelLayoutFor` 从 `lane.freeMarkerPanel` 读位置 ⇒ 面板纹丝不动
(既没移动也没报错,看起来像"拖拽没生效")。补上 `case EdgePanelKind::MARK` ⇒ 写入 `freeMarkerPanel`。
### E. 面板与网格的隔离
- `OnMouseMove`:先 `TopPanelAtPoint`,命中则把探针置空(光标模式下不再画穿过面板的光标),
固定点 ✕ 的悬停判定也一并跳过。
- `OnLButtonDown`:自由标记的两个分支(固定点 ✕ / 登记固定意图)都加了 `freeOverPanel` 守卫,
确保面板上的按下永远不会被网格抢走。
### F. 验证
- `.preflight/markcheck` 重写并扩到 **61/61**:
[A] 默认无光标(`LaneFreeMarkerCursor == false`)、贴近曲线才有效、远离曲线单击不固定、
拖动仍是平移、预览点落在悬停容差内; [B] 贴近曲线单击恰好固定 1 个点,再补 2 个共 3 个;
[C] 面板只给有固定点的信号发名称行、四列有宽度/不重叠/首列与信号名同左边界/全在行 ✕ 左侧、
行按时间升序、首项无增量;
[D] 底部按钮首次点击 → 光标模式开启(远离曲线也有效、`cursorMode == true`)、再点关闭、
按钮点击不会固定任何点;
[E] 光标模式下拖标题栏**确实移动面板**、拖动不固定点、内容不丢;
[F] 鼠标移到面板上 ⇒ 探针被清空(不画光标);
[G] 关掉光标模式后:选区放大里原地单击=固定且不缩放、拖动=缩放且不固定;
[H] 编辑 Δt 后该点移动并在越过后续点时变成最右项;
[I] 行尾 ✕ 只删 1 项、信号名 ✕ 清空该信号全部、清空后面板不再发任何信号行;
[J] 深度放大 `snapped == true` 且时刻精确等于采样时刻、视界外固定点不绘制。
- 回归:`editcheck` 20/20、`round3check` 31/31、`round4check` 34/34、`round5check` 5/5、
`convcheck` 18/18、`check` **136/137**(唯一失败项仍是那条已知不稳定项)、
真实 MF4 仍 **283** 条信号。
- `make release` / `make debug` **零警告**;`Build/Release/Target.exe` **834,560 字节(815.0 KiB)**。
---
## 本轮(2026-10-21 第二轮,本机):「自由标记」体验重做 + 全部实验性面板可折起
> 用户需求(逐条):
> ① 网格内固定点的**取值显示互相重叠遮挡** ⇒ 加避让;取值后面的 ✕ 改成**与取值同高的正方形**;
> 固定点**不再显示纵向半透明竖线**;
> ② 面板底部的按钮改为只显示「**光标选取模式**」,交互外观与工具栏的切换类按钮一致(激活高亮),
> 激活时在按钮**上方**出现「**标记透明度**」滑动条(外观与信号样式区的「透明度」同款),
> 它控制本网格内**所有固定点显示内容**的透明度;
> ③ 面板内容重排:与信号列表的「组 → 信号」类似 —— 每个有固定点的信号一个**信号色托底分组栏**
> (左:展开/收起三角 + 信号名;右:`N 标记点` + ✕;文字/三角/✕ 都用**自适应对比色**),
> 点击分组栏展开/收起;条目五列:**当前值 / 枚举文本 / 增量 / 标记点时刻 / 相对前一点的时间增量**,
> 列之间纵向对齐、互不重叠;整个面板都没有枚举文本时**缺省枚举列**;首项增列与 Δt 列显示 `N/A`;
> 每行末尾仍有 ✕;
> ④ 内容过多时面板要有**上下滚动条**(分组栏 + 条目一起滚动),底部按钮与滑动条**不参与滚动**;
> ⑤ **所有**实验性功能浮动面板都支持展开/折起:GX 徽标左边加**三角 + 横线**指示器,
> 单击折起/展开,收起的面板只有标题栏,双击标题栏也可展开。
### A. 数据层(`Utilities`)
- `LanePanelState` 新增 `bool collapsed = false`(`open` 仍然表示“功能是否显示”,二者语义不同)。
- `Lane` 新增:`float freeMarkerOpacity = 1.0f`(网格内固定点显示不透明度)、
`float freeMarkerScroll = 0.0f`(面板内容滚动偏移)、
`std::vector<SignalRef> freeMarkerCollapsedGroups`(面板里被收起的信号分组;**不在列表里 = 展开**)
+ `FreeMarkerGroupCollapsed()` / `ToggleFreeMarkerGroupCollapsed()`。
- `LaneCarry`(`RebuildLanes`)与 `TabConfig::SplitLane` 这两处“网格重建/拆分时的状态继承”都带上新状态,
并对五个面板都补了 `*.collapsed` 的拷贝。
### B. 网格内固定点
- **标签避让**:`PaintFreeMarker` 逐条记录已放置的胶囊外接矩形;新胶囊与它们相交时按
`+1、-1、+2、-2 …` 步长(步长 = 胶囊高 + 2px)**纵向错开**,并夹在网格可视范围(`clipTop..clipBottom`)内。
- **✕ 正方形**:`FreeMarkerLabelRects` 里 `closeSize = textHeight`(胶囊高度),宽高相等。
- **删除纵向竖线**:固定点不再画那条 `WithAlpha(signalColor, 0x70)` 的竖线(跟随光标模式的光标线仍在)。
- **标记透明度**:圆点、胶囊底色、描边、文字、✕ 都乘 `lane.freeMarkerOpacity`;
⚠️ **GDI `DrawTextW` 忽略 alpha**(`SetTextColor` 只吃 RGB)⇒ 半透明时文字改由
`DrawFreeMarkerAlphaText()`(GDI+ `DrawString` + alpha brush)绘制,100% 时仍走 `UiText`
(像素零变化)。不透明度 ≤ 2% 时**整个胶囊什么都不画**,且**不登记 ✕ 命中矩形**
(看不见的按钮不应可点)。
### C. 面板布局(`EdgePanelLayout` / `EdgePanelLayoutFor`)
- 新增字段:`disclosure`(标题栏左侧三角指示器,宽 `kEdgePanelDisclosureWidth = 20`)、
`collapsed`、`content`/`scrollTrack`/`scrollThumb`/`markerContentHeight`、
`opacityTrack`/`opacityValue`、`markerGroups`(`MarkerGroup{ref, header, triangle, close, collapsed}`)、
`markerHasEnumColumn`;`markerColumns` 由 4 列扩到 **5 列**
(`MARKER_COL_VALUE/ENUM/DELTA/TIME/DT`)。
- `GX` 徽标右移一个 `disclosure` 宽度;**折起**时 `height = titleHeight` 并在算完
`title/close/disclosure/badge` 后直接 return(内容区/按钮/滚动条都是空矩形)。
- MARK:内容总高 = Σ(分组栏 + 未收起组的条目),`viewHeight = min(总高, 内容上限)`;
内容上限 = `min(kFreeMarkerContentMaxHeight=260, 绘图区高 − 标题 − 3×pad − 按钮 − 滑块行)`。
超高 ⇒ `scrollTrack` 宽 `kFreeMarkerScrollWidth=7`、滑块高度按内容比例、位置按滚动比例;
同时把内容宽度收窄 `scrollW + 4px`,保证五列 + 行 ✕ 不会与滚动条重叠
(列宽因此调整为 `{74, 80, 60, 72, 78}`,面板宽 `424`)。
- 只登记**与内容区相交**的分组栏与条目矩形 ⇒ 滚出视界的条目不会抢走底部按钮/滑块的点击
(交互侧再用 `UiPointInRect(layout.content, …)` 兜一层)。
- 枚举列:只查“这些信号是否带枚举表”(`SignalEnumText::Empty()`,O(1)),
**不逐条采样**(布局函数被 `TopPanelAtPoint`/`SyncPanelOrder` 高频调用)。
### D. 绘制
- 标题栏:先画「三角(朝下 = 展开 / 朝右 = 折起)+ 三角右侧一条短横线」,再画 GX 徽标与标题。
- MARK 内容区:`g.SetClip(content, Intersect)` 后绘制;分组栏用 `WithAlpha(signalColor, 0xE8)` 托底,
三角 / 名称 / `N 标记点` / ✕ 全部用 `UiContrastInk(signalColor)` 的自适应对比色;
条目五列左对齐在**布局给定的列起点**上(超长 `UiEllipsize`),
`值 | (枚举) | ±增量 or N/A | <时刻>s | Δt:+… or N/A`。
- 底部:按钮改为**切换类按钮**外观(激活 = `accentSoft` 底 + `accent` 描边 + `textStrong`),文案固定
「光标选取模式」;激活时其上方是「标记透明度」行(左侧标签 + 轨道 + 右侧百分比,
与信号样式区的「透明度」行同款 `UiDrawSlider`)。
### E. 交互
- 新增拖拽模式 `MARKER_OPACITY`(拖滑块 → `SetLaneFreeMarkerOpacity`)与
`MARKER_SCROLL`(拖滚动条滑块 → `SetLaneFreeMarkerScroll`,含抓取偏移与轨道点击跳转)。
- `OnMouseWheel`:鼠标在 MARK **内容区**上时滚轮翻页面板(±40px/格),否则照旧滚网格;
底部按钮/滑块区域**不**翻页。
- `OnLButtonDown` 顺序:✕ → GX 徽标 → **disclosure** → 逐条目参数 → MARK 底部按钮 → 透明度滑块 →
滚动条 → 分组栏(✕ 清空该信号 / 其它位置切换收起)→ 行尾 ✕ → 标题栏拖动 → 面板主体消费。
- `OnDoubleClick`:标题栏(排除徽标/✕/三角)**双击切换折起**;MARK 条目仍按列边界编辑时刻/间隔。
- 悬浮:分组栏三角悬停高亮(`m_freeMarkerTriangleHoverLane/Signal`),✕ 悬停沿用
`m_freeMarkerPanelHover*`;光标形状:三角/分组栏/✕ = 手型,透明度滑块 = 左右箭头,滚动条 = 上下箭头。
### F. 验证
- 新增离屏 `.preflight/perf/mark2check.exe` **69/69**:
[A] 固定点:单点无竖线(与“无标记”帧逐像素对比,竖线列差异必须为 0)、✕ 正方形、
三点胶囊纵向避让(✕ 矩形互不相交且 Y 不同);
[B] 面板:一个分组栏(三角/✕ 在托底内)、三行条目、五列不重叠、枚举列缺省(宽 0);
[C] 分组收起后只留分组栏、行数归零、面板变矮,再次点击恢复;
[F] 五类面板折起后高度 == 标题栏高度且内容全空(含 COUNT 面板);
[D] 40 个固定点 ⇒ 内容超可视区、滚动条出现、滑块在轨道内;滚到最大时滑块贴底、
分组栏与条目一起移出、滚轮在内容区可翻页而在按钮区不翻页;
[E] 光标模式开关 ⇒ 滑块出现/消失、面板高度随之变化;点击轨道 25% ⇒ 不透明度 ≈25%;
不透明度 0% 与“无标记”帧逐像素相同(1433 → **0** 像素差异)。
[G] 额外导出 `mark2check_shot*.bmp`(人工目视检查用,不入库)。
- **A/B**:临时恢复固定点竖线 ⇒ 「no translucent vertical line」如实失败;
临时禁用避让(`attempt <= 0`)⇒ 「capsules do not overlap」如实失败。恢复后 69/69。
- 回归:`datacheck` 69/69、`verify` 49/49、`panelanim` 158/158、`errcheck` 19/19;
`make release` / `make debug` **零警告**;`TargetCheck.exe` **866,816 字节**。
⚠️ `verify.exe` 在打印完结果后**退出时**仍会 0xC0000005(`git stash` 回到本轮改动前的基线
同样复现 ⇒ 探针自身的既有问题,与本轮无关)。
---
## 本轮(2026-10-21 第三轮,本机):拆分网格功能归属 + 阶跃线宽 + 面板残影 + 自由标记性能/交互
> 用户需求(逐条):
> ① **BUG**:两个信号独立网格 → 给其中一个开实验性功能 → 「合并网格」→「拆分网格」后**第二个网格也
> 开启了实验性功能**;「自由标记」更严重:合并再拆分后第二个网格上出现**野生的固定点**
> (第一个网格标记点的镜像),但新网格的浮动面板里又没有这些点的信息。
> ② **BUG**:`TestData/SCATTER_TEST.csv` 的 step 信号在默认 1px 线宽下,**垂直阶跃段的线宽明显
> 大于水平持续段**;把跳变时刻放大后线宽又正常。
> ③ **BUG**:拖动一个浮动面板经过另一个浮动面板时,下方/其它面板上留下**拖拽残影**。
> ④ **BUG**:自由标记打上大量固定点后,左右拖动全局时间光标变卡(移动视界/滚动网格偶发卡顿)。
> ⑤ **改进**:所有浮动面板的折起指示器与信号列表组名的折叠三角**同款**;GX 徽标悬停**不改光标**、
> 不显示"是否在视界内"的浮动提示;标题栏**双击**把目标网格滚动到视界内(单击不触发);
> 标题栏悬停不改光标,只有按下左键拖动时才是十字箭头。
> ⑥ **改进**:标记透明度独立于光标模式按钮(始终显示);悬停某个固定点的数值显示时该显示恢复全不透明
> (但若鼠标正瞄准被它遮住的数据点则不恢复);固定点数值与面板数据列**不出现省略号**。
### A. 拆分网格:实验性功能只属于"首个信号"(§11.2 第 203–204 条)
- 根因两处:`RebuildLanes` 把"开着"的面板状态**按信号逐个**继承(`inheritedCarry(ref)`),而合并后的
网格里每条信号都指向同一份状态 ⇒ 拆回每信号一格时**每一格**都带着功能;`SplitLane` 则直接把
源网格的 `*.open` 复制给每一份。
- 修法:`LaneCarry` 新增 `owner`,只有 `lane.signals.front()` 为 true;`applyCarry` 对
`!carry.matched || !carry.owner` **直接 return**(保持默认 = 功能全关)。`SplitLane` 只让
`signalSlot == 0` 的那一份继承(并把 `freeMarker / freeMarkerCursor / freeMarkerPanel /
freeMarkerOpacity` 一并补上——旧实现漏了自由标记)。
- "野生标记点"的真正来源:条目是**按网格整体**携带的,拆分后每份都拿到了全部 `freeMarkerItems`。
新增 `dmdetail::PruneLaneSignalItems`(按 `lane.signals` 过滤四类逐信号条目 + 收起分组),
在 `RebuildLanes` 末尾与 `SplitLane` 的每一份上都调用。
### B. 阶跃信号的竖直沿比水平段粗(§11.2 第 205 条)
- 仅发生在**包络带路径**(可见采样数 > 绘图区宽度)。根因:横向膨胀半径用
`ceil(halfWidth / step)`,而 1px 线宽的 `strokeHalf = 1 × m_scale × 0.5 ≈ 0.625` 设备像素
⇒ `ceil` 得到 1 列 ⇒ 竖直沿从"1 个像素列"变成 3 列(≈3px),水平段却只有 ~1.25px。
放大到走逐点描边路径(线宽天然一致)后就"正常"了——与用户描述完全吻合。
- 修法:`radius = floor(halfWidth / step)`(1px 不膨胀;≥2 列宽仍按整数列膨胀,10px 仍被撑粗)。
实测(125% 缩放、1px 线宽):**水平 1px / 竖直 1px**(改前 1 / 2)。
### C. 面板拖拽残影(§11.2 第 206–207 条)
- 根因链:`PanelGlobalKey` 里混了 4 类面板的 `x/y`(拖动任一面板 ⇒ 所有面板重画,15 个 ≈33ms/帧),
但**漏了 `freeMarkerPanel`**;于是拖「自由标记」面板时其它面板照旧贴旧像素,而它们上一帧的缓存里
含被拖面板的图像 ⇒ 残影。另外只靠"新矩形与谁相交"的级联重画是漏的:面板可能已移到**不与任何
面板相交**的位置,而别处的缓存像素里还留着它的旧图像。
- 修法两条:① 面板位置**从 `PanelGlobalKey` 移除**(它只影响自己,`PanelSlotKey` 已含
`layout.panel`),只保留 `open / collapsed` 与 `edgeValue`;② 绘制前预扫描 `m_panelSlots`,
与本帧布局的缓存矩形比较,不相等(或被移除)的旧矩形进 `vacatedRects`,任何与之相交的面板强制重画。
- 附带收益:拖动单个面板不再重画全部面板(原来 15 个 ≈33ms/帧)。
### D. 大量固定点时的卡顿(§11.2 第 210 条)
- 每个固定点每帧都要 `ResolveSignal` ×2 + `SampleDisplayedSignalAt` ×2 + 拼 5 段文本 + 量宽度,
而拖光标/平移时这些值**根本没变**。新增 `PlotView::FreeMarkerContents(laneIndex)`(`mutable` 缓存),
条目 `FreeMarkerContentEntry{capsuleText, valueText, enumText, deltaText, timeText,
deltaTimeText, color, value, valid}`;键只混"条目 + 样式 + 数据地址/长度",**不含视界与鼠标**。
网格内胶囊与面板五列共用它(`FreeMarkerLabelRects` 也改读缓存)。
### E. 面板外观 / 交互(§11.2 第 208–209 条)
- 折起指示器:改成与 `SignalPanel` 组名**完全相同**的 `UiDrawTriangleIcon`(12 逻辑像素、悬停变 accent),
去掉三角后方那条短横线。
- `QueryCursor`:GX 徽标与标题栏悬停都返回 `nullptr`(箭头);`EDGE_PANEL_MOVE` 拖拽中仍是 `IDC_SIZEALL`。
- 删除 `PaintPanelBadgeTooltip`(含声明)——徽标只保留高亮,不再弹"已在视界内 / 点击滚动到该网格"。
- `OnDoubleClick`:标题栏(排除 ✕ / 徽标 / 三角)改成 `ScrollLaneIntoView` + 提示;
`DoubleClickIsPlainPress` 对面板默认 `true`,**只有标题栏**返回 false(否则双击永远到不了 `OnDoubleClick`)。
### F. 自由标记:透明度 / 悬停恢复 / 不省略(§11.2 第 211–212 条)
- 「标记透明度」滑块行**始终**显示(面板高度算式同步去掉 `freeMarkerCursor` 条件)。
- 悬停取值胶囊 ⇒ 该胶囊 `alpha = 1`;例外:`m_freeMarker.valid` 且其 `(x, y)` 落在胶囊矩形内
(正瞄着被遮住的数据点)时保持原透明度。
- 不省略:`Lane::freeMarkerColumnWidths[5]`(绘制该面板时按真实文本量宽度存回 `Lane`),列宽取
「缺省列宽」与它的大者、面板宽度随之撑开、每列多留 2px;胶囊宽度余量 10 → 12px。
### G. 验证(全离屏,未占用鼠标/键盘/屏幕)
- 新增 `.preflight/fixcheck`(`build.ps1` + `main.cpp`)**37/37**:
[A] 合并→拆分的功能归属 + "没有别人的固定点";[B] 阶跃线宽(水平 vs 竖直,跳过图例色块);
[D/E] 内容缓存命中(平移视界键不变 / 改样式键失效)、滑块始终存在且在按钮上方、
列宽 ≥ 实测文本、悬停恢复不透明、探针遮挡时保持半透明;
[C] 面板残影(拖动中的一帧 vs 作废面板图层后的重画,逐像素);
[F] 徽标/标题光标、单击不滚动、双击滚动、双击不折起。
- **A/B(判别力已证)**:`ceil` 还原 ⇒ 竖直 2px vs 水平 1px ⇒ 1 项 FAIL;`carry.owner` 还原 ⇒
功能归属断言 FAIL;禁用 `vacatedRects` ⇒ 残影 111 像素 ⇒ FAIL。恢复后 37/37。
- 回归:`round3check` **31/31**(其中「徽标显示手型光标」是用户本轮明确推翻的旧规则,已改为箭头)、
`round4check` 34/34、`round5check` 5/5(1px→2px、10px→26px 仍成立)、`editcheck` 20/20、
`convcheck` 18/18。
- `make release` / `make debug` **零警告**;`TargetCheck.exe` **852,480 字节(832.5 KiB)**。
- ⚠️ 探针坑:`Grab` 用 `GetDIBits(app.m_backDc, app.m_backBitmap, …)`;残影只存在于"另一块面板的
**缓存矩形**"里,比较区域要取两个缓存矩形的交集(面板主体不透明会盖住大半残影);直接改
`lane.*Panel.x/y` 就等价于"拖动中的某一帧"(走鼠标注入会先 `BringPanelToFront` 并改全局键,
那一帧所有面板都重画,反而看不到残影)。
---
## 本轮(2026-10-22 第四轮,本机):「自由标记」面板表头 / 吸顶 / 行选中与悬停联动 + 原位显示 + 探测容差与阶跃吸附
> 用户需求(逐条):
> ① 面板在每个分组栏下方增加一行**列表头**(标记值 / 增量 / 枚举文本 / 时间点 / 时间间隔),
> 数据行按列对齐,一行 = 一个固定标记点;滚动时分组栏与表头**吸顶**,到组末尾按
> 「表头 → 最后条目 → 分组栏」的次序依次滚出;所有固定点都没有枚举文本时**缺省枚举列**。
> ② 条目行可**选中**(高亮);行**悬停**时对应的固定点在网格内(若在视界内)要连数值显示与
> 数据点标记一起高亮。
> ③ 固定点取值胶囊恢复**原位显示**(允许遮挡,回到初版方案)。
> ④ 非光标模式的"鼠标接近标记"范围改成**与常规触发信号曲线高亮一致**。
> ⑤ `TestData/SCATTER_TEST.csv` 的阶跃信号:默认视图下**竖直跳变线上点不中、也没有吸附提示**。
> ⑥ 滚动时**文字串到面板标题栏**上。
### A. 面板:表头行(§11.2 第 213–214 条)
- `EdgePanelLayout::MarkerGroup` 新增 `tableHeader`(一行列标题)与 `pinned`(是否吸顶)。
- 新常量 `kFreeMarkerHeaderRowHeight = 18`、`kFreeMarkerColumnLabels[5]`
(**顺序与 `markerColumns` 一致**:标记值 / 枚举文本 / 增量 / 时间点 / 时间间隔)。
- 内容总高与 `EdgePanelWidth` 都计入表头行;列宽 = max(缺省列宽, 实测内容宽度, **列标题宽度**)
⇒ 表头与数据都不会被省略。枚举列仍按 `SignalEnumText::Empty()` 整列缺省。
- 交互侧不登记表头(它只用于阅读),点击表头落到"面板主体"(消费、不穿透)。
### B. 吸顶:每个元素各自钉住(§11.2 第 215–216 条)
- 公式:`y = max(自然位置, min(吸顶位置, 本组下边界 − 自身高度))`,分组栏与表头**各自**计算:
分组栏吸到内容区顶、表头吸到分组栏下方 ⇒ 组末尾时表头先被推走(被分组栏盖住),
最后一个条目随后滚到分组栏下,分组栏最后离场。探针实测:表头脱顶 502 设备像素 < 分组栏离场 550。
- 绘制顺序固定为 **条目 → 表头 → 吸顶分组栏**:否则向上滚的条目会盖住吸顶元素;
分组栏底衬改为**完全不透明**(先铺信号色再叠 0xE8,观感不变)。
- 被吸顶区域盖住的条目**既不绘制也不登记命中**(`coveredByPinned`),否则会隔着分组栏点到条目。
### C. 行选中 / 行悬停(§11.2 第 217 条)
- 数据层:`Lane::freeMarkerSelected`(-1 = 未选中),随 `LaneCarry` 与 `SplitLane` 携带;
`RemoveFreeMarker / RemoveFreeMarkerAt / RemoveFreeMarkersOf` 会校正下标(删到自己 = 取消,
删在它前面 = 下标前移,整组清空 = 若选中项属于其它信号则重新定位)。
- 交互:`PlotView::SelectLaneFreeMarker(lane, index)`;单击行的数据区域(✕ 之外)选中、
再点同一条取消。
- 新状态 `m_freeMarkerRowHoverLane/Item`(**与“✕ 悬停”分开**:整行悬停时不能让该行的 ✕
变成 danger 色——那是“准备删除”的语义)。悬停该行时网格内对应固定点:数据点加 accent 圆环、
胶囊强制不透明并加 2px accent 描边(只在视界内显示)。
- 面板里选中行用 `WithAlpha(accent,0x3C)` 整行底色、悬停行用 `0x1E`;
`PanelSlotKey` 混入 `freeMarkerSelected`(悬停靠“鼠标在面板内时混坐标”已有的机制)。
### D. 固定点恢复原位显示(§11.2 第 218 条)
- 删除 2026-10-21 的纵向避让(`placed/occupied/totalRect`):胶囊直接画在数据点处,允许互相遮挡
(用户明确要求回到初版方案)。
### E. 探测容差与阶跃竖直沿(§11.2 第 219 条)
- `ProbeFreeMarker` 改成**按“鼠标到画出来的折线”的垂直距离**判定:复用 `AcquirePolyline` 顶点 +
与 `HitTestSignal` 相同的窗口二分与点到线段距离(散点模式比较顶点),
容差 = **`kSignalHitTolerance`(6 逻辑像素)** —— 与常规触发曲线高亮**完全同一范围**。
- 命中后把时刻**吸附到最近采样时刻**(非光标模式恒吸附;光标模式仍只在稀疏判据成立时吸附),
保证固定点落在真实数据点上。旧的 `kFreeMarkerHoverTolerance = 8` 已删除。
- 根因:旧实现只看“同一横坐标的采样值之差”,而阶跃**竖直段**那一列上下两个采样值是 ±1,
都不等于鼠标高度 ⇒ 竖直跳变线上永远探测不到、也固定不了。
### F. 滚动时文字串到标题栏(§11.2 第 220 条)
- GDI `DrawTextW`(`UiText`)**不认 GDI+ 裁剪**:内容区上方“部分可见”的条目会把文字画进标题栏。
MARK 面板里所有文字(条目单元格、表头列标题、分组栏名称与计数、空提示)改走
`UiTextClipped(..., layout.content)`(同一套 `DrawTextW` 光栅化 + `SaveDC/IntersectClipRect`)。
### G. 验证(全离屏,未占用鼠标/键盘/屏幕)
- 新增 `.preflight/mark4check` **38/38**:
[A] 表头存在/位置/高度、列宽容纳列标题、枚举列缺省与恢复;[B] 吸顶位置、被压条目不可命中、
表头先脱顶且分组栏后离场;[C] 选中/取消、悬停记录与网格高亮(胶囊区域像素变化)、
离开清除;[D] 两个近同时刻固定点的胶囊 Y 相同(无避让);[E] 阶跃竖直沿探测 + 单击固定到
真实采样时刻 + 3/15 像素内外容差;[F] 逐档滚动比较标题栏区域像素(必须为 0)。
- **A/B(判别力已证)**:禁用吸顶 ⇒ 分组栏不再钉住;改回 `UiText` ⇒ 标题栏 196 像素差异;
跳过竖直段 ⇒ 竖直沿探测/固定 2 项失败。
- 回归:`markcheck` 61/61、`fixcheck` 37/37、`round3check` 31/31、`round4check` 34/34、
`round5check` 5/5、`editcheck` 20/20、`convcheck` 18/18;
`make release` / `make debug` **零警告**;`TargetCheck.exe` **855,552 字节(835.5 KiB)**。
- ⚠️ 探针坑:两条信号同网格时阶跃幅度更小(阈值取 40 逻辑像素);
吸顶“离场”必须在**两组**内容上验证(单组且内容超高时分组栏数学上会一直钉住);
滚动扫描步长按设备像素(`step*2`),再乘 `m_scale` 会扫不到组末尾。
## 本轮(2026-10-23 第五轮,本机):「自由标记」常规模式回归普通鼠标 + 两个显示开关 + 序号列/列分隔线 + 行悬停压暗 + 目录规范化
### A. 目录规范化(§11.2 第 221 条)
- `Inc` 只放**头文件**(含 `*Internal.hpp`),`Src` 只放 `.cpp`。四个 `*Internal.hpp` 已 `git mv` 到 `Inc`:
`Utilities/Inc/DataModelInternal.hpp`、`Components/Application/Inc/ApplicationInternal.hpp`、
`Components/PlotView/Inc/PlotViewInternal.hpp`、`Components/SignalPanel/Inc/SignalPanelInternal.hpp`。
- 历史原因:它们原来放在 `Src` 也能编译,因为 `#include "XxxInternal.hpp"` 会先找**源文件所在目录** ——
一旦文件被搬走就无声失效。所有 include 都是扁平形式,靠 `-I.../Inc` 解析,因此搬家**无需改任何 include**。
- ⚠️ `Makefile` 里 `CXX_INCLUDES_PATHS` 上方新增的注释块**不能和该变量写在同一行**(写在同一行会把整个变量注释掉,
症状是 `fatal error: CsvLoader.hpp: No such file or directory`)。本轮踩过一次,已修。
- ⚠️ `Makefile` 是所有 `.o` 的前置依赖 ⇒ 改注释也会触发全量重编(`-MP` 已加 phony 目标,`.d` 不会把构建卡死)。
### B. 常规模式 = 普通鼠标(§11.2 第 222 条)
- `ProbeFreeMarker` 开头新增早退:`if (!lane.freeMarkerCursor) return probe;`(未开启「自由标记点选取」时
不预览数据点、不画纵向光标、`OnLButtonUp` 也不会固定任何点)。`OnLButtonDown` 的固定意图登记同样加上该前提。
- 面板空提示文案按状态区分:未开启 ⇒「开启「自由标记点选取」后单击网格可固定数据点」;已开启 ⇒「在网格内单击以固定数据点」。
- ⚠️ 只有**贴着曲线**(点到折线距离 ≤ `kSignalHitTolerance`)的信号才是可标注目标:`ProbeFreeMarker` 里
`if (!hitCurve) continue;`。少了这一句,纵向光标“整列都在”时 `probe.ref` 会一直有效 ⇒ 任意位置单击都能固定。
### C. 两个显示开关(§11.2 第 223 条)
- 按钮文案「光标选取模式」→「自由标记点选取」(功能不变,`kFreeMarkerCursorModeLabel`)。
其下方新增一行两个半宽切换按钮「显示增量 | 显示枚举文本」,`Lane::freeMarkerShowDelta/freeMarkerShowEnum`
(默认 `false`,`LaneCarry` 双向携带,`SplitLane` 复制到 primary 分支)。
- **只影响已固定点**的胶囊文本;跟随鼠标的预览胶囊恒为 `FreeMarkerValueText(value,false,false,0,true,enumText)`
(即“值 = 枚举”,不受开关影响)。面板统计列不看这两个开关。
- 布局:`EdgePanelLayout` 新增 `showDelta/showEnum`;底部固定区自上而下 = 透明度滑块 → 主按钮 → 开关行,
行间距 `kFreeMarkerButtonGap = 4`;`switchRow` 计入面板高度与内容区高度
(`height = title + pad + view + pad + footer + pad`,`footer = sliderRow + buttonH + gap + switchRow`)。
### D. 六列 / 序号 / N/A / 列分隔线(§11.2 第 224 条)
- 列序:`序号 / 标记值 / 枚举文本 / 增量 / 时间点 / 时间间隔`(`kFreeMarkerColumnLabels[6]`、
`kFreeMarkerColumnWidths[6] = {34,74,80,60,72,78}`、`Lane::freeMarkerColumnWidths` 由 5 → 6)。
- `MarkerRow::serial`(组内 1 起);序号列**恒存在**,用 `m_theme->textMuted` 绘制。
- 枚举列:只有当**某个有固定点的可见信号**带枚举表时才出现;出现时其它无枚举的点、以及数值对不上任何枚举项的点
一律显示 `N/A`(`kFreeMarkerNotAvailable`)。
- 列与列之间画竖线:`cell.left + cell.width + gap*0.5`,颜色 `WithAlpha(borderStrong, 0x6C)`、1px,
数据行与表头都画。
### E. 行悬停压暗 / ✕ 提示移除(§11.2 第 225–226 条)
- 悬停面板某行 ⇒ 该网格其它固定点(数据点 + 胶囊)不透明度封顶 **50%**(原本就 ≤ 50% 的不变),
目标行强制 100%(并保留 accent 圆环 + 2px 描边);移出立即恢复。
⚠️ 命中登记(`m_freeMarkerHits`)也要用压暗后的 alpha 判定,否则会出现“看不见却能点”。
- 删除固定点 ✕ 的悬停浮动提示:`PaintFreeMarkerTooltip` 的**实现与声明都已删除**,不要加回来。
- 信号悬停提示(`PaintSignalInteraction`):该网格开启「自由标记点选取」时文案**只显示信号名**
(与「选区放大」模式同理:左键已专用于固定标记点,拖动也不再是拖信号)。
### F. 缓存键两处补齐(§11.2 第 227–228 条)
- `FreeMarkerContents` 的内容缓存键新增**枚举表的规模与 `texts.data()` 地址**:
旧键只混信号指针与采样数量 ⇒ 给信号加枚举表之后条目里仍然是 `N/A`(离屏探针实测复现)。
- `PanelSlotKey` 新增 `mixRect(layout.showDelta/showEnum)` 与两个开关布尔值:
否则点开关 / 布局变化后会贴回旧像素。
### G. 验证(全离屏,未占用鼠标/键盘/屏幕)
- 新增 `.preflight/mark5check` **52/52**:[A] 常规模式无探测/不固定;[B] 三按钮几何、
两个开关默认关闭且点击可双向切换、点击开关不穿透、不启动拖动;[C] 六列 + 序号 1 起 + 无枚举时缺省枚举列;
[D] 枚举命中 LOW/HIGH、对不上 ⇒ `N/A`;[E] 开关只改已固定点胶囊(默认 `1` / `+增量` `1(+2)` /
`+枚举` `1 = HIGH` / 全开 `1(+2) = HIGH`),**预览胶囊区域像素差 = 0**;[F] 每个列边界都检测到竖线;
[G] 行悬停时另一固定点胶囊像素变化 848px,移出恢复为 0;[H] 选取模式下悬停气泡变窄(1023 → 830,拖拽提示已去掉)。
- 回归(探针已按新语义更新,全部重新链接当天代码):`markcheck` **67/67**(A 段改为“普通鼠标不探测/不固定/拖动仍平移”,
D 段改为“按钮双向切换”,C 段左边缘断言改为序号列)、`fixcheck` **37/37**(列宽断言扩到 6 列含序号)、
`mark4check` **40/40**(E 段先开启选取模式,且“远离曲线”改判 `probe.ref` 无效)、
`round3check` 31/31、`round4check` 34/34、`round5check` 5/5、`editcheck` 20/20、`convcheck` 18/18、
`diag` 19/19;`check` **136/137**(唯一失败项 `the plot is not corrupted by a panel-animated frame` 的
`drift=15818` 与**本轮改动前逐位相同**,是该探针既有的不稳定项)。
- `make release` / `make debug` **零警告**;`TargetCheck.exe` **857,600 字节(837.5 KiB)**。
- ⚠️ 探针坑:`PlotView::FreeMarkerProbe::valid` 在选取模式下**恒为真**(纵向光标整列都在),
判断“是否命中曲线/是否有可固定的点”必须看 `probe.ref.IsValid()`;
在固定点上做像素 A/B 时,固定点若落在悬浮面板下方(绘图区右侧)像素永远不会变 ⇒ 固定点的时间要放在左侧。
## 本轮(2026-10-27 第六轮,本机):浮动面板事件穿透 + 自由标记“总是预览最近信号” + 固定点位于 GX/图例之下 + 预览曲线取值插值 + 体积红线 900KB
> 用户需求(逐条):
> ① 浮动面板上右键会触发下方网格的右键菜单;面板上的点击 / 右键 / 拖拽等操作不得穿透到下层。
> ② 「自由标记点选取」应**总是**预览网格内离鼠标较近的那条信号的数据点(同一时刻 A 比 B 更近 ⇒ 预览 A),
> 现状是“即使只有一个信号,也必须把鼠标贴到曲线附近才会出现预览点”,远离曲线时无法预览、也无法左键固定。
> ③ 固定数据标记点不得遮挡网格的 GX 标签与图例:数据点与数据显示应位于它们**下方**。
> ④ 预览曲线(均值滤波等)的光标数值点会“悬空”(光标落在两个采样点之间时不落在曲线上),要求在所有带预览曲线的功能中修好。
> ⑤ 产物体积红线由 800KB 放宽到 **900KB**(到达阈值提醒即可,不是禁止超过)。
### A. 浮动面板不得被右键 / 中键穿透(§11.2 第 230 条)
- 新增 `PlotView::OnRButtonDown(int x, int y)`:`TopPanelAtPoint` 命中即 `BringPanelToFront` + `m_dragMode = NONE`,
返回 true(该次右键被面板消费)。`Application::WM_RBUTTONDOWN` 在“绘图区右键 → 网格菜单”**之前**调用它。
- `PlotView::OnMButtonDown` 开头同样判定浮动面板:命中即消费,**不进入平移**(否则会在面板上按住中键拖走整个绘图区)。
- 左键 / 双击的层序原本就正确(`OnLButtonDown` 的面板分支、`OnDoubleClick` 的面板分支),本轮未改。
### B. 「自由标记」总是预览网格内离鼠标较近的信号(§11.2 第 231 条)
- `ProbeFreeMarker` 删掉 `if (!hitCurve) continue;`,改成**两个候选取更近**:
* 曲线候选 = 鼠标到画出来的折线的垂直距离(阶跃竖直段只有它能命中);
* 采样候选 = 鼠标时刻吸附到最近采样后,该采样点的**屏幕纵向距离**。
两者都不设“必须命中”的门槛,`tolerance` 只用于决定是否画“已吸附”圆环(`snapped = sparse`)。
- 逐信号的 `distance` 取两者的较小值,因此“同一时刻下谁离鼠标更近就预览谁”在单信号与多信号下都成立。
- `snapToNearestSample` 仍然对两个候选生效 ⇒ 固定点始终落在真实采样时刻上(第 229 条不变)。
### C. 固定标记点位于 GX 徽标与图例之下(§11.2 第 232 条)
- `PaintFreeMarker` 开头(在 `g.SetClip(m_plotRect, Intersect)` 之后)收集遮挡矩形:`m_legendHits` 的全部托底 +
每个网格的 `LaneBadgeRect`,逐个 `gd::Region::Exclude` 出当前裁剪区,再 `SetClip(..., CombineModeReplace)`。
标记点(圆点 / 胶囊 / ✕ / 预览胶囊)因此**完全画不进**这些区域,图例与徽标的像素(含胶囊圆角之外的抗锯齿边)保持原样。
- ⚠️ **GDI 文字不认 GDI+ 裁剪**:`PaintFreeMarkerCapsule` 新增 `vectorText` 参数,胶囊矩形与任一遮挡矩形相交时
强制走 GDI+(`DrawFreeMarkerAlphaText`),否则文字会盖到图例上面。
- ⚠️ 试过并放弃的替代方案:画完标记点后**把图例原样重画一遍**(`PaintLegendOverlay`)。
它与装饰缓存的 `AlphaBlend` 结果不完全一致,实测图例胶囊圆角处残留 **240 像素**差;纯裁剪方案为 **0**。
- 只在“本网格确实有固定点 / 探针正停在本网格”时才做区域运算,没有标记时零开销。
### D. 预览曲线取值点落回曲线(§11.2 第 233 条)
- `paintDerivedCapsule` 不再取“最近采样点”的曲线值,改为在光标准确时刻对派生曲线**按时间线性插值**:
`locate(displayTime)` 用二分求两侧采样下标与比例(非单调时间轴退回最近采样),
再**先 `ApplyDrawTransform` 后插值**(绝对值 / 垂直镜像不是线性变换,先插值会偏离折线)。
- 请求窗口由“用到的那些源下标”拼出(仍收窄,不会因光标远离视界而重算整条曲线)。
- 双光标增量(`(+Δ 中心轴)`)同样走插值;显示数值现在也是**变换后**的值(与画出来的曲线一致)。
- 中心轴 / 均值滤波 / 弱峰调整共用同一段代码,因此三处一起修好。
### E. 体积红线 900KB(§11.2 第 234 条)
- `Makefile` 的 `RELEASE_OPT` 注释更新:**到达 900KB 必须提醒用户**(不是禁止超过),旧 800KB 红线作废。
### F. 验证(全离屏,未占用鼠标 / 键盘 / 屏幕)
- 临时离屏探针(仓库外 `%TEMP%\jdi_probe`,链接 `Build/Release/*.o` 去掉 `Main.o`)**25/25**:
* [A] 面板上右键被 `PlotView` 消费且**不弹菜单**、面板外右键仍弹菜单;面板上中键不进入平移、面板外仍平移;
* [B] 单信号 + 鼠标远离曲线 ⇒ `probe.ref` 有效、单击可固定、时间吸附到采样;两信号同网格 ⇒ 视鼠标高度分别选中 hold / step;
* [C] 加一个会压在图例上的固定点前后两帧:图例托底 + GX 徽标矩形内像素差 **0**,同时整幅差异 > 5 万像素(证明标记点确实画了);
* [D] `SCATTER_TEST.csv` 的 step、视界 0.998056–1.00091、光标 0.999277、均值点数 2 / 迭代 1:
均值滤波 0.0px、中心轴 0.0px、弱峰调整 0.2px(与曲线逐像素贴合);插值点与最近采样点相差 39px / 197px。
- **A/B 判别力**:临时恢复 `if (!hitCurve) continue;` ⇒ [B] 5 项如实失败;临时把插值改回“最近采样点” ⇒ 均值/中心轴各自偏 36px 并如实失败。
- `make release` / `make debug` **零警告**;`Target.exe` **860,672 字节(840.5 KiB < 900KB)**,比上一轮 +3 KB。
- ⚠️ 探针坑:`CycleCursorMode()` 是**循环**(无→单→双→无),在多组用例里每组都调一次会让后几组“没有光标”而误判为失败;
面板 / `LaneVisual` / `LegendHit` / `EdgePanelLayout` 都是 `PlotView` 的嵌套类型,探针里必须写 `PlotView::` 前缀。
- ⚠️ 本轮开始前发现工作区里 `PlotViewPaint.cpp` 与 `Documents/agent_mem.md` 被**回退到 `8f735d5`**(丢失第四/五轮全部改动),
已 `git checkout --` 恢复到 `HEAD` 后再施工;提交前务必确认这两份文件与 HEAD 一致。
## 本轮(2026-10-28 第七轮):统一组件协议 + 去手写链路 + 大文件按职责拆分
> 用户需求:① 全面放弃手写链路,UI 绘制尽量走"控件树 + 派生基类"(先确认不是负优化);
> ② 存在大量冗余的文件按职责拆分,但不为拆分而过度抽象;③ 一轮代码审查(错注释/过期注释/名实不符);
> ④ 把临时测试工具 `Tools/` 从提交树剥离并写入 ignore;⑤ 更新 Documents 下的计划/进程/报告。
### A. `UiComponent`:顶层组件的统一协议(新增 `Components/Shared/Inc/UiComponent.hpp`)
纯接口、**无状态**,7 个方法:`SetScale / Layout / Area / Paint / HitTest / OnMouseLeave / IsEditing`
(外加默认返回 `nullptr` 的 `QueryCursor`)。
`Toolbar` / `TabBar` / `SignalPanel` / `PlotView` 各自 `: public UiComponent`,
它们本来就有一模一样签名的方法 ⇒ **只加 `override` 与 `Area()`,实现零改动**。
⚠️ **不把 `UiWidget` 当这个基类**(四条契约冲突,已写进头文件注释):
`UiWidget` 是"树上的节点"(父给尺寸、父下探脏区、命中走树链、`OnPaint` 只画自己);
顶层大组件是"一块地盘的宿主"(`PlotView` 4 层自持位图缓存、`SignalPanel` 行位图缓存、
`PopupMenu`/拾色器是模态浮层)。两者是**兄弟关系**,不是父子。
`Toolbar`/`TabBar` 同时属于两者:对外实现 `UiComponent`,对内持有一棵 `UiWidget` 树。
### B. 宿主侧:手写链 → 组件表
`Application` 新增 `struct ComponentEntry { UiComponent* component; Target target; bool chrome; }`
与 `std::vector<ComponentEntry> m_components`(底→顶:plot → panel → toolbar → tabBar),
`BuildComponentTable()` 在 `Initialize` 里 `Setup` 之后装配一次。
被表驱动的四处(旧实现每处都逐个点名组件):
| 消息 | 旧 | 新 |
| --- | --- | --- |
| `ApplyDpi` | 6 行逐个 `SetScale` | 1 个循环 |
| `Relayout` | 4 行逐个 `Layout`(且手算三组坐标) | 循环 + `UiStackRects` 切分 |
| `WM_MOUSELEAVE` | 4 行逐个 `OnMouseLeave` + 分别累计 | 循环 + `chrome` 标志决定"整帧/只绘图区" |
| `WM_SETCURSOR` | `if (绘图区命中) … else if (面板) …` | 从表尾往前找首个命中者,其 `QueryCursor` 生效 |
**仍是显式的(有意)**:左键按下/抬起、鼠标移动、编辑态吸收、分隔条拖拽 ——
这些分支各自带"按下后要提交重命名 / 构建派生信号 / 联动滚动 / 更新拖拽光标"等**专属后续动作**,
强行表驱动会把它们变成藏在表里的 lambda,反而更难读(正是"过度抽象")。
顺带兑现一个历史缺口:`UiLayout` 的 `UiStackRects` 第一次有了**生产调用点**(内容区三列切分)。
### C. 大文件按职责拆分(纯搬移,逐像素 0 差异)
| 原文件 | 现状 | 说明 |
| --- | --- | --- |
| `SignalPanelInteraction.cpp` 2 329 行 | **831 行** | 拆出 `SignalPanelEdit.cpp`(616) / `SignalPanelScroll.cpp`(205) / `SignalPanelMenu.cpp`(527) |
| `Shared/PopupMenu.cpp` 1 901 行 | **706 行** | 拆出 `PopupMenuLayout.cpp`(753) / `PopupMenuPaint.cpp`(397) + 新增 `Inc/PopupMenuInternal.hpp`(103) 收敛尺寸常量与 `PaintResetIcon` |
拆分判据是**职责**,不是行数:面板按"编辑 / 动画滚动 / 菜单 / 交互"切;
菜单按"几何排版 / 绘制 / 交互"切。文件级常量与辅助函数搬进 `*Internal.hpp`
(命名空间 `pmdetail`,`constexpr`/`inline`),跨 TU 安全。
### D. 代码审查(本轮修掉的问题)
1. `SignalPanelInteraction.cpp` 头部注释写着"分片按职责划分为核心/渲染/交互与编辑"(只有 4 片),
实际已是 6 片 ⇒ 改为完整的六分片清单。
2. `PopupMenuLayout.cpp` 机械搬移时**把构造函数也搬了进去**(与 `PopupMenu.cpp` 重复定义)
⇒ 删重复、只保留在交互分片。
3. 拆分后 `PopupMenu.cpp` 的 `#include "PopupMenuInternal.hpp"` 一度落在 `namespace jdi` **内部**
(等价于声明 `jdi::jdi`)⇒ 移到文件头。
4. `PopupMenuPaint.cpp` 缺 `UiControls.hpp`(`UiDrawEditFieldContent` 未声明)⇒ 补 include。
5. 全仓复查了历史删除符号的残留引用(`m_paintPlot` / `ClampCursorsAfterPan` /
`PaintFreeMarkerTooltip` / `CaptureDecorCache` / `FitSinglePointSignal` / `CsvTable` / `DataFile` /
`.preflight` / `structcheck`)⇒ **零命中**,无需修改。
### E. 验证
* `make release` / `make debug` **零警告**;`TargetCheck.exe` **891 392 B**(< 900 KB),
`Target.exe` **886 784 B**(与改造前一致)。
* `Tools/rendercheck`:A–H 八个场景哈希与改造前**逐一相同**
(`A 2166933039626F66 … H 23F390DD76141113`),7 条功能断言全 PASS。
* `Tools/uicheck`:**69 passed / 0 failed**。
* 体积:`UiComponent` 是纯接口,静态派发几乎零开销;`TargetCheck` 比 `Target` 多出的
4.6 KB 来自 `PopupMenuInternal.hpp` 的 `inline` 函数在 3 个 TU 各实例化一份
—— 这是拆分的已知代价,仍远低于 900 KB 阈值。
### F. 工具目录剥离
`Tools/`(`uicheck` / `rendercheck`)已从**提交树**剥离并写入 `.gitignore`
(历史提交里也不含它 —— 见台账的「分支操作取证」)。本地文件保留,仍可随时复跑。
---
## 本轮(2026-10-28 第八轮,本机):显示布局边界(Android 风格)+ 工具栏设置菜单(齿轮)+ 三处可见收益优化
### 1. 交付的功能
* **齿轮按钮**(`ToolbarAction::SETTINGS`,位于「导入文件」**左侧**)→ 展开应用设置菜单。
工具栏布局改为「左侧固定簇(`kLeftClusterCount = 2`:齿轮 + 导入文件)+ 右侧按钮组」。
* **设置菜单**:`MenuScope::APP_SETTINGS`(新增枚举值,宿主自己处理、不下发组件);
条目 = `HEADER "调试"` + `NORMAL "显示布局边界"`(`keepOpen = true`,点击保持菜单打开)。
* **显示布局边界**:`UiComponent::PaintDebugBounds(g, scale)` 新增虚函数(默认只画 `Area()`),
四组件覆盖上报内部结构;`UiWidget::PaintDebugBounds(g, scale, depth)` 递归整棵控件树。
### 2. 硬约束(再改时必须守住)
1. **调试层绝不进任何位图缓存**(`PlotView` 4 层缓存 / `SignalPanel` 行位图缓存)。
只在 `Render` 最后一步叠加 ⇒ 关掉开关后逐像素还原(探针 `bounds-off-restores-pixels` 钉住)。
2. **开启时必须整帧回送**:`fullFrame` 已并入 `m_showLayoutBounds`。
否则"只回送绘图区"的快路径会让装饰区边界停在旧帧上,看起来像没生效。
3. **只描边、不填充**(Android 一致):几十个嵌套矩形叠加填充会把内容糊住。
4. **线宽按设备像素固定**(不乘 `scale`)+ 临时 `PixelOffsetModeNone` + 关抗锯齿 + 整像素对齐,
否则 1px 线会被摊成 2px 灰边,无法判断真实边界。
5. 层级配色:`0` 青蓝 `#29B6F6`(2px) / `1` 品红 `#E040FB` / `2` 琥珀 `#FFB300` / `≥3` 绿 `#66BB6A`(1px)。
6. `Toolbar`/`TabBar` 上报时**跳过根控件**(它与组件区域完全重合,重复画会压住自己的按钮)。
### 3. 可见收益优化
| 位置 | 内容 |
| --- | --- |
| `Toolbar::Layout` | 控件树改为**按需重建**(`m_treeDirty`):只在规格/字体/主题/缩放/动态文本变化时重建。拖动分隔条每帧 `Relayout` 不再重建。 |
| `Toolbar::SetActionLabel` | 只写 `m_labelOverrides`(唯一权威)+ 置树脏,不再双写控件文本。 |
| `UiWidget::DispatchRedraw` | 新增;`Toolbar`/`TabBar::OnMouseMove` 的 `Dispatch(); return UiRectHasArea(TakeDirty());` 收敛到这里(`TakeDirty()` 会清零,漏一次就误判"无变化")。 |
### 4. 验证
```
make release -> 零 error 零 warning;TargetCheck.exe 897,024 B (< 900KB)
.preflight/lbcheck (新增离屏探针) -> 21 passed, 0 failed
INFO area-pixels=24930 node-pixels=19734
INFO gear=(15,55 50x37.5) menu=(15,97 185x73.8)
rendercheck 工具栏带以下区域哈希(改前 == 改后,8/8 相同)
AB AABF558F499583EA BB 1B8B2D389D41B988 CB CAC17AA78C515079 DB 154617738CD9C422
EB DA484B02EEEC778A FB B3CE740F60CF3688 GB 4DD14C0C2EC4CE91 HB D84400B723089F43
rendercheck FUNC 7/7 PASS; uicheck 69 passed
```
> 整帧哈希 A–H 因为多了齿轮按钮而整体变化(预期)。要证明"改动只落在工具栏内",
> 必须用**工具栏带以下的区域哈希**。
### 5. 验证坑(踩过的)
* 取"改造前"基线要 `git stash push -u`(**含未跟踪文件**);否则新增的 `UiDebugBounds.cpp` /
`UiComponent.cpp` 仍被 Makefile 的 `Src/*.cpp` 通配扫到 → 引用不存在的成员而编译失败。
* 区域哈希起点必须用**运行时** `m_toolbarRect.Y + Height`;写死 `70` 会切进工具栏,
齿轮正好落在统计区间内,导致本应相同的哈希全都不同。
* 图标不要用字体字符 `⚙`(不同回退字体大小/基线不一致);用 GDI+ 矢量按 `half` 比例画。
---
## 本轮(2026-10-28 第九轮,本机):布局边界观感重做(四角粗/四边细)+ 全覆盖 + 几何同源
### 1. 边界描边的硬规则(**再改动必须守住**)
| 部位 | 规则 |
| --- | --- |
| 四边 | 固定 **1 设备像素**(四条独立实心矩形;**不要用 `DrawRectangle`**,笔宽奇偶会偏半像素) |
| 四角 | 额外加粗:层级 0 = **4px**、其余 **3px**,沿水平/垂直各延伸 `cornerLen`(≤14px 且 ≤ 短边一半) |
| 内缩 | 层级 > 0 整体内缩 **1px** ⇒ 父子矩形共边时不会叠成粗线 |
| 填充 | **绝不填充**(几十层嵌套填充会把内容糊住) |
| 对齐 | 整像素对齐 + `PixelOffsetModeNone` + 关抗锯齿 ⇒ 1px 就是 1px |
「四角粗」不只是观感:**多条边重合时靠角的形状分辨层次**(父=青蓝大包角、子=同位置品红小包角)。
### 2. 协议:组件"收集 box",宿主统一绘制
```
class UiDebugBoxList; // 值类型:{rect, depth, tag}
virtual void UiComponent::CollectDebugBoxes(UiDebugBoxList&); // 默认只登记 Area()
void UiWidget::CollectDebugBoxes(boxes, depth); // 递归整棵控件树
Application::PaintLayoutBounds(...) // 收集完按 depth 升序一次性描边
```
⚠️ **不要**改成"所有元素继承同一基类":`PlotView` 4 层位图缓存 / `SignalPanel` 192 槽行位图缓存
要求绘制归组件自己,几何进继承体系会与缓存键/失效体系打架。box 是值类型、不参与绘制,
所以能让**绘制/命中/调试共用同一份几何**且零生命周期成本。
### 3. 覆盖清单(本轮补齐)
- `Toolbar`:区域 / 按钮 / **按钮内容(文字或齿轮)**
- `TabBar`:区域 / 标签条 / 新建按钮 / 每个 ✕
- `SignalPanel`:4 分区 + 筛选框/清空✕/全部显示 + 滚动轨道/滑块 + 样式 5 行与 12 色块/自定义/3 模式/3 调整/线宽与透明度轨道和数值 + 目标行/名称/GX + **每个可见行**的折起三角/复选框/色块/名称/GX/✕(组行另有组名/计数)+ 内联编辑框
- `PlotView`:区域 / 每网格 / GX 徽标 / 装饰层矩形 / 图例托底+色块+文本 / **5 类浮动面板全部 20+ 字段**
- `PopupMenu`:浮层 / 条目 / 可视区 / 轨道 / 滑块 / 二级子菜单与条目
- `ColorPickerPopup`:面板 / 标题 / 预览 / 色号 / 色域 / 色相条 / 3 条滑轨与数值 / 输入框
标签有**尺寸门槛**(< 56×16 逻辑像素不画):小方框紧邻时逐个小框贴字会糊成一片。
### 4. 性能与一致性
- `RowRect(row)` 曾是 **O(row)** 逐行累加,而画一行要调 **5 次**(✕/徽标/名称/组计数/折起三角)
⇒ 200 行只看 30 行时每帧上万次加法,命中测试同样走这条路。
现在用 `m_rowTop` 前缀和(由 `UpdateAnimGeometry()` 统一重建)⇒ **O(1)**;
长度不一致时**退回逐行累加**(正确性优先)。
- `ContentOffsetBeforeRow()` 同样用前缀和。
- `PanelBounds()` 收敛 6 处 `(m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area`。
### 5. 图标
* 齿轮按钮 `primary = true`(与「导入文件」同为强调色主按钮)。
* 齿轮矢量几何:**齿占最外一圈(0.66R→1.0R)、外环中心线 0.54R、笔宽 0.24R**。
⚠️ 齿若从环中心线伸出会被环的描边吃掉,只剩"甜甜圈"。
### 6. 验证
```
make release / make debug 零 error 零 warning;TargetCheck.exe 910,336 B(889 KiB)
.preflight/lbcheck 21/21(含 bounds-off-restores-pixels)
uicheck 69/69
rendercheck 「工具栏带以下」区域哈希 8/8 与上一轮逐一相同 + FUNC 7/7
```
### 7. 踩坑
* 用 PS 按行替换大段代码时,找函数结尾必须 `Trim() -eq "}"` 而不是 `TrimEnd()`:
文件里的闭括号带前导缩进,用 `TrimEnd()` 会越界(`InvokeMethodOnNull`)且**开头已被删掉**,
要在替换后逐个核对括号配对。
* 中文不能写进 `.ps1`(PS 5.1 按 GBK 读)⇒ 文本块落 UTF-8 数据文件、脚本保持纯 ASCII。
---
## 本轮(2026-10-28 第十轮,本机):布局边界自适应 + 绘制残留与层级缺陷修复
### 1. 描边规则(**现行,取代第九轮的固定包角**)
以**短边**为基准,不再用固定 3–4px 包角:
| 量 | 规则 |
| --- | --- |
| 四边 | 固定 1 设备像素 |
| 包角粗细 | `min(4(层级0)/3(其余), 短边/10)`,夹到 `[1, 短边/4]` |
| 包角长度 | `min(14, 短边/3)`,且 ≥ 包角粗细 |
| 短边 ≤ 12 | **只画四个 L 形角标记、不画边** |
| 短边 ≤ 5 | 完全不画 |
| 内缩 | 仅层级 > 0 **且短边 ≥ 10** |
文字标签已**整体删除**(含 `UiDebugPaintStyle`):黑底白字在小元素上必糊,且省 ~1.5 KB。
### 2. 调试叠加层的三条硬约束(血泪)
1. **绝不读位图坐标系的缓存**:`m_decorRects` 是装饰层位图缓存的登记矩形(负 Y / 超出绘图区),
拿它当屏幕矩形会出现横跨工具栏的幽灵巨框。屏幕几何请用 `LaneBadgeRect` / `m_legendHits`。
2. **调试开启时必须强制整幅重绘**:`PlotView::SetForceFullRepaint(true)` 关掉三条快路径
(静态场景贴图 / 宽基底贴图 / 样式补丁),否则局部重画会让上一帧的边框残留
(症状:拖网格滚动条时工具栏与网格区域出现大量残留,松开并移动鼠标才消失)。
3. **box 必须带 `clip`(组件地盘)**:组件内元素可能越界(滚出可视区的 `lane.rect` Y 为负),
宿主按 `Area()` 设置裁剪,`Add()` 跳过无交集、绘制时求交。
### 3. 层级:排序 + 挖洞
* `UiDebugBoxList::PushLayer()`:宿主每收集完一个组件 +1(菜单/拾色器最后)。
绘制按 `(layer, depth)` 稳定排序 ⇒ 后收集的压住先收集的。
* ⚠️ 仅排序**不够**:调试线画在所有内容之后,下层组件的线仍会显示在不透明菜单上。
因此 `AddOccluder()`:菜单/拾色器登记本体为遮挡矩形,绘制**更低层** box 时用
`Region::Exclude` 挖洞。实测菜单区 / 子菜单区下层颜色像素 = **0**。
* ⚠️ 浮层**不要**设 `clip = 本体矩形`:二级子菜单会展开到本体之外,会被整个裁掉
(改用它自己登记的 occluder)。
### 4. 验证
```
lbresid 5/5 toolbar-unchanged-while-scrolling / repaint-same-state-is-identical
/ toolbar-restored-by-full-repaint / residual-never-above-plot-area
/ later-layer-wins-over-earlier
lbzorder 4/4 menu & submenu:amber=0 green=0
lbcheck 21/21 ;uicheck 69/69;rendercheck 区域哈希 8/8 不变 + FUNC 7/7
零 warning;TargetCheck.exe 906,752 B(885.5 KiB)
```
### 5. 验证方法论(重要)
判断"是否有绘制残留"**不能**用"拖拽帧 vs 松开后一帧":两者图层预热进度不同,
会产生几十万像素的**合法**差异。正确判据是 **同一状态连续两帧必须逐像素相同**
(探针里先跑若干帧直到收敛,再比较相邻两帧)。
### 6. 新增探针
* `.preflight/lbresid`:MF4-Record.MF4 / 283 网格 / 拖滚动条,5 条断言 + BMP 落盘。
* `.preflight/lbzorder`:打开网格右键菜单与「实验性功能」子菜单,检查浮层区内无下层颜色。
两个探针都在 `.preflight/`(已被 gitignore),需在 `make release TARGET_FILE_NAME=TargetCheck`
之后用各自的 `build.ps1` 构建。
---
## 本轮(2026-10-28 第十一轮,本机):取消内缩 + 文本几何身份 + 补全漏项 + 描边提速
### 1. 边界描边的**现行**规则(取代第十轮的"内缩"版本)
| 部位 | 规则 |
| --- | --- |
| 四边 | 固定 1 设备像素 |
| 四角 | 自适应角块:`min(4/3, 短边/10)` 夹 `[1, 短边/4]` |
| **内缩** | **绝不内缩** —— 框必须等于"可见形状的外接矩形" |
| 短边 ≤ 12 | 只画 4 个角块,不画边 |
| 短边 ≤ 5 | 完全不画 |
| 图示元数 | **8**(4 边 + 4 角块);⚠️ 不要用 `GraphicsPath`/`FillPath`,单次约 20µs,比 12 段还慢 |
⚠️ 第九轮曾为"父子共边不叠粗"对 `depth > 0` 内缩 1px,**已作废**:胶囊形元素(GX 徽标 30×17、
圆角 = 高的一半)的可见边界就在外接矩形上,内缩会让圆弧留在框外。
正确语义是"子框后画 ⇒ 覆盖父框的那条边",不需要缩小子框。
### 2. 文本几何:在唯一出口回报(不要逐处登记)
`PaintUtil` 新增:
```
class UiTextRectSink { virtual void OnTextRect(const gd::RectF&) = 0; };
void UiSetTextRectSink(UiTextRectSink*); // 开关关闭时为 nullptr
void UiSetTextRectOffset(float dx, float dy); // 离屏位图渲染的坐标换算
UiTextRectSink* UiGetTextRectSink();
```
`DrawUiText()` / `UiTextAtClipped()` 画完后按对齐方式 + 实测宽高把矩形**收窄成墨迹矩形**再回报。
`UiDebugBoxList` 直接继承它 ⇒ 宿主只要 `UiSetTextRectSink(&m_debugBoxes)` 一句。
**不要**改成在 65 处调用点逐个登记(必漏)。
### 3. 缓存命中也要有文本框(关键坑)
面板行是"画进位图再贴回",命中缓存时**不调用 `UiText`** ⇒ 绘制期收集器收不到任何东西。
做法:行渲染时用本地 `RowTextCollector` 收集(位图坐标)→ 存进 `RowBitmapCache::textRects`
→ 贴图时按 `(originX, originY)` 回放。**必须无条件收集**:
只在"外层有收集器"时收集会导致"关调试时滚过列表 → 打开调试 → 命中缓存 → 永远没有文本框"。
### 4. 描边性能
图元数量是唯一瓶颈(每次 `FillRectangle` ≈1µs)。12→8 图元后 `paintBoxes` **5.892→4.028ms**。
`GraphicsPath`/`FillPath`(≈20µs/次)、GDI `PolyPolygon`(5688 矩形 114ms)都更慢,别换。
### 5. PlotView 已补齐的漏项
`m_timeAxisRect` / `m_scrollBarRect` / `BackToTopRect()` / `m_cursorSpanRect` /
`m_cursorLabelRects` / `EditFieldRect()` —— 全部读现成成员。
### 6. 验证
```
lbcovr 7/7 · lbcheck 21/21 · lbresid 5/5 · lbzorder 4/4 · uicheck 69/69
rendercheck 整帧与区域哈希与上一轮逐一相同 + FUNC 7/7(调试功能不影响正常绘制)
零 warning;TargetCheck.exe 909,824 B(888.5 KiB)
```
### 7. 待办(已确认的既有问题)
`PlotView` 基线每帧在 `WarmLaneLayersStep()` 上花约 2.4ms(9.06 vs 6.66),
与本功能无关,值得单独优化。
---
## 本轮(2026-10-29 第十二轮,本机):分支同步 + 既有性能问题 + 浮层层级修复
### 0. 仓库约定变更
* 远端 `gitea` 已把 `UI_Refactor` 合入 `main`(PR #1,合并提交 `322b658`),**分支保留**。
* `main` 与 `UI_Refactor` 均已同步到 `322b658`。
* **`dev` 分支已删除**(远端从未存在,用户确认为意外产物)。
### 1. 「既有性能问题」的结论修正(重要)
上一轮报告里"`PlotView` 基线每帧多花 2.4ms"**是错的**:
```
排空前 idle 2.60 ms ← 含"图层预热队列排空"的瞬时成本
排空后 idle 0.53 ms ← 真正的稳态
强制整帧 idle 0.46 ms
```
那 2.4ms 是载入 283 网格后给 16 个网格补建宽基底图层的**瞬时**开销(每帧补 1 个,约 16 帧结束)。
⚠️ **性能结论必须区分"稳态"与"瞬时"**,否则会把预热成本误判成常态浪费。
顺着这条线确实修掉一处顺序问题:`RollWideBuffer` 原来对**所有** 283 个网格先算
`LaneLayerKey()`(逐网格内容哈希 ≈0.7ms/帧)再判断可视性;且离屏网格的"结构不可用"分支
会逐帧把它们塞进待重画队列。**现在把 `LaneVisuallyRelevant` 判断提到循环最前面**
(见函数内注释),离屏网格既不哈希也不入队。
### 2. 浮层不被下层覆盖 = 三件事(缺一不可)
| 机制 | 作用 |
| --- | --- |
| `PushLayer()` + `(layer, depth)` 排序 | 决定两个盒子重叠时**谁压谁** |
| `AddOccluder()` + `Region::Exclude` | 让**更低层**的盒子整体不进入浮层区域 |
| `SetClip()` | 让**滚动容器里滚出的条目**不再登记/绘制(否则会"闪烁"/"溢出") |
* `PlotView::CollectDebugBoxes`:收集浮动面板前 `PushLayer()`,每个面板
`AddOccluder(layout.panel)` + `SetClip(layout.panel)`(面板内部元素也不越界)。
* `PopupMenu::CollectDebugBoxes`:条目裁到 `m_scrollEnabled ? m_scrollViewport : m_rect`;
二级子菜单条目裁到 `m_subRect`。
### 3. 验证
```
lbpanel 10/10(新增:面板遮挡 / 面板内容裁剪 / 滚动不闪烁 / 菜单条目裁剪 / 真滚动 1295px)
lbcovr 7/7 · lbzorder 4/4 · lbresid 5/5 · lbcheck 21/21 · uicheck 69/69
rendercheck 8 场景哈希与上一轮逐一相同 + FUNC 7/7(本轮不改变任何正常绘制像素)
零 warning;TargetCheck.exe 909,824 B(888.5 KiB)
```
### 4. 踩坑
* 探针判断"盒子是否越界"必须**按绘制层过滤**:信号面板样式区、标签栏 ✕ 等盒子可能恰好与
菜单同列,直接按坐标判会误报"菜单条目溢出"。
* 构造「所属网格」菜单必须带**分隔线 + 尾部固定项**,否则 `PopupMenuLayout` 的滚动区不启用
(`pinnedFrom` 只在找到分隔线时才 > 0)。
* 计时探针必须先**排空预热队列**再测稳态,否则首帧的图层补齐会污染平均值。
### 5. 已知近似
面板层只比网格层高 1,而**文本盒子**固定在 layer 2 收集 ⇒ 面板自己的文字框正常显示,
但"图例文字"若与面板重叠仍会画在面板上(当前布局下不重叠)。彻底解决需要让文本收集器
在面板绘制期间使用面板层的层号。
## 2026-10-29 第十三轮(本机):体积 L3 落地(−120,832 B)+ L1 几何产物层 + 审计
> 本轮回答的是"**在不拆 DLL、不改 `-Oz`、不改变任何像素**的前提下把体积降下来,
> 关异常后不牺牲健壮性,并把几何从调试绘制里解耦"。
### 1. 体积:909,824 B → **788,992 B(−120,832 B,−13.3%)**
分两步,每步都可独立回退:
| 步骤 | 手段 | 体积 | 段变化要点 |
| --- | --- | ---: | --- |
| 基线 | — | 909,824 | — |
| ① | `-fno-exceptions` + `-ffunction-sections -fdata-sections` + `-Wl,--gc-sections` | 882,688 | `.xdata` 48,132→34,152、`.pdata` 35,424→34,524、`.text` −15 KB |
| ② | ① + `LTO=1`(`-flto` 编译与链接) | **788,992** | `.text` 657,168、`.rdata` 35,016、`.xdata` 27,512、`.pdata` 28,524 |
**Makefile 三个开关**(默认全为 1,可命令行覆盖):
```
LAYOUT_BOUNDS=1 # 编入「显示布局边界」叠加层(LAYOUT_BOUNDS=0 可剔除)
NO_EXCEPTIONS=1 # 追加 -fno-exceptions
LTO=1 # 追加 -flto(编译+链接)
```
派生变量(定义在 `LDFLAGS` **之后**,靠 make 递归变量"展开 recipe 时才求值"生效):
| 变量 | `LTO=1` | `LTO=0` |
| --- | --- | --- |
| `FUNC_SECTIONS` | **空** | `-ffunction-sections -fdata-sections` |
| `ASM_LISTING` / `ASM_LISTING_C` | **空** | `-Wa,-a,-ad,-ahlms=$(@:%.o=%.lst)` |
| `GC_SECTIONS` | `-Wl,--gc-sections` | 同左 |
### 2. ⚠️ LTO 的两个坑(都实测复现,务必保留结论)
1. **切换 LTO 必须 `make clean`**:LTO 与非 LTO 目标文件混链 ⇒ GCC 15.2.0
在 `binds_to_current_def_p (symtab.cc:2598)` 处 ICE。
2. **`-flto` 与 `-ffunction-sections -fdata-sections` 同时打开也 ICE**
(即使全部干净重编也必崩;报错落点是 `Utilities/Inc/DataModel.hpp:393` 的 `struct SignalData`,
但那只是 COMDAT 解析落点,**不是**原因)。
⇒ LTO 下 `FUNC_SECTIONS` 置空,并加 **`-flto-partition=one`**:
默认多分区 LTRANS(11 个分区)稳定复现,单分区从未复现。
**`-flto-partition=one` 不是性能选项,是稳定性选项,不要删。**
代价:LTRANS 串行,全量构建慢约一倍。
3. LTO 下 `-Wa,-a,-ad,-ahlms` 的 `.lst` 汇编清单不再生成(lto-wrapper 丢弃并告警)⇒
需要看汇编时用 `make release LTO=0`。
### 3. 关异常必须成对交付:预检验 + 兜底(新增 `MemProbe`)
`Utilities/Inc/MemProbe.hpp` + `Utilities/Src/MemProbe.cpp`:
* `MemAvailablePhysical()` —— `GlobalMemoryStatusEx` + **200ms 缓存**;失败返回 `SIZE_MAX`(视为充足)。
* `MemCanAllocate(bytes)` —— 预留后仍须保有 1/8 可用内存(否则 Win 会疯狂换页,表现为"卡死"而非"失败")。
* `MemReserveIfRoom(container, n)` —— 含 `capacity()` 早退 + 乘法溢出保护;
预留失败语义上只是"少了一次性优化",`push_back` 自行增长,**结果依然正确**。
* `MemInstallTerminateHandler()` —— `std::set_terminate`,OOM 时弹一次带建议的模态提示后 `_Exit`。
接入点:
* `Utilities/Src/DataModel.cpp`:全仓**唯一**的 `try/catch`(`extremes.reserve(count/8)` 捕
`std::bad_alloc`)→ `MemReserveIfRoom(extremes, count/8)`。
* `Components/PlotView/Src/PlotView.cpp`:`AvailablePhysicalBytes()` 原有**一份完整重复实现**
(`GlobalMemoryStatusEx` + 200ms 缓存)→ 转发 `MemAvailablePhysical()`。
* `Components/Application/Src/Application.cpp`:`Initialize()` 开头 `MemInstallTerminateHandler();`。
⇒ 全仓已无 `try/catch`(除 `MemProbe` 注释中的说明)。
### 4. L1 几何产物层(新增 `UiBox.hpp/.cpp`,`UiDebugBounds` 退化为消费者)
* `struct UiBox { gd::RectF rect; gd::RectF clip; int depth; int layer; }` —— **纯值类型**,
不继承任何东西(继承会和 `PlotView` 4 层位图缓存 / `SignalPanel` 行位图缓存的失效体系打架)。
* `class UiBoxTable : public UiTextRectSink` —— 只累计几何 + `SetClip` / `PushLayer` / `SetLayer` /
`AddOccluder`,**不含任何绘制代码**。
* `UiComponent::CollectDebugBoxes` → **`virtual void BuildBoxes(UiBoxTable&)`**(默认只 `Add(Area(), 0)`)。
语义从"收集布局边界(调试用)"变为"上报几何产物",组件**不再需要知道"调试"这个概念存在**。
* `UiDebugBounds.hpp/.cpp` 只剩 `UiDebugBoundsColor` / `UiDebugPaintBoundsClipped` /
`UiDebugPaintBoxList(g, const UiBoxTable&)` —— 唯一的消费者。
* 只有宿主 `ApplicationCommands.cpp` 同时包含两者;`SignalPanel.cpp` / `PlotViewPaint.cpp` /
`PopupMenu.cpp` / `ColorPickerPopup.cpp` / `UiWidget.cpp` / `TabBar.cpp` / `Toolbar.cpp`
只包含 `UiBox.hpp`。
⚠️ 几何与绘制**都必须保留的语义**(改动一次就会让之前的 bug 复发):
* `SetClip(组件 Area())` —— 组件内部元素可能落在地盘外(滚出可视区的 `lane.rect` Y 为负),
不裁剪就会登记到工具栏/标签栏的地盘上。
* 每收集完一个组件 `PushLayer()` —— 后收集的压住先收集的(二级菜单压住行)。
* 浮层 `AddOccluder(本体)` —— 仅靠排序只能决定"两个 box 重叠时谁压谁",
不能阻止"下层组件的边框画在不透明浮层上面"。
* 浮层**不要**设 `clip = 本体`(会把展开到本体外的二级子菜单整个裁掉)。
### 5. L2(`UiDamage` 脏矩形 + S4 独立叠加层)**评估后缓做**
理由(三条,缺一不可):
1. 受益方只有"**调试开关打开时**的帧率"。`SetForceFullRepaint` 只在
`m_showLayoutBounds` 为真时置位,普通用户路径本来就是快路径。
2. 正确实现无法只改 `PlotView` 内部:宿主用的是**持久后备缓冲区**,
上一帧叠加层画上去的边框像素必须在下一帧被底层内容重画覆盖;改成"独立叠加位图 + 只混合脏区"
就必须把叠加层旧矩形**注入**底层组件的重绘范围 ⇒ 穿透 `PlotView` 四条快路径 + 两层缓存。
3. 收益/风险不成比例:拖拽错位、贴图残留是用户反复报过的事故类型。
保留:`SetForceFullRepaint` 及四处守卫、`UiBoxTable` 几何。将来要做时,
几何可直接喂 `UiDamage`,不需重新收集。
### 6. 审计(本轮顺手做的)
* `MemProbe` 注释里"省 40~60KB"的估算 → 改为实测 **27,136 B**。
* `MemProbe.hpp` 的 `<new>`、`MemProbe.cpp` 的 `<cstdio>` —— 未使用,删除。
* `UiDebugPaintBounds(g, rect, depth)` —— **零调用者**(唯一调用者是它自己),删除;
只保留带 `clip` 的 `UiDebugPaintBoundsClipped`。
* `TabBar` / `Toolbar` / `PlotView` / `SignalPanel` 的 `BuildBoxes` 文档里"(调试用)"→"几何 box"。
* `PlotView::SetForceFullRepaint` 文档补写"为什么保留而不优化"(即 L2 缓做理由)。
* 源码中旧名 `UiDebugBoxList` / `UiDebugBox` / `CollectDebugBoxes` **零残留**。
### 7. 验证
```
uicheck 69/69 lbcheck 21/21 lbresid 5/5
lbzorder 4/4 lbcovr 7/7 lbpanel 10/10 (合计 116 条断言)
rendercheck: A FAED59DF284153DB / B E6443BFE4E3955F8 / C 96A0AA8C402F1729 / D E3D5DBD998E43720
E DA0583CB13C26A6F / F FFB822159F604D1D / G 19F8DE8FE3A8DF18 / H DBBDDE4CB2C97C92
FUNC 7/7 PASS, exit=0
```
**8 个哈希与基线逐一相同**;`make release` / `make debug` 均零 warning。
### 8. ⚠️ 探针脚本与 LTO 的耦合
`.preflight/*/build.ps1` 与 `Tools/*/build.ps1` 链接的是 `Build/Release/*.o`。
**LTO 下这些是 LTO 字节码**,因此 7 个脚本的链接参数都补了 `-flto -flto-partition=one`
(对非 LTO 目标文件无害)。这些脚本目前**未被 git 跟踪**;
若 LTO 长期默认开启,重建探针前记得补这两个参数,否则链接失败。
### 9. 体积纪律(不变)
预警阈值 **1MB(1,048,576 B)**,不是硬上限。超阈值时**必须主动提醒用户**并说明
"超在哪一段(`.text` / `.rdata` / `.xdata`+`.pdata` / `.rsrc`)、由哪个改动贡献"。
查段构成的标准命令:`objdump -h Build\Release\JustDataInspector.exe`。
当前 788,992 B,余量 259,584 B。
## 2026-10-29 第十四轮(本机):「适应视图」跟随时间轴偏移 + 派生信号不再为空
> 用户报告两条功能异常(都源于「调整 → 时间轴」偏移 + 派生信号烘值):
> ① 给信号设置时间轴偏移后点「适应视图」,视界没有跟着平移;
> ② 用「均值滤波 / 弱峰调整 → 构建为新信号」派生出来的信号是**空的**。
### 1. 硬规则变更(**推翻旧结论,务必按此**)
| # | 已作废的规则 | 现行做法 |
| --- | --- | --- |
| 1 | 时间边界只取 `axis.minTime/maxTime`(源时刻) | **必须叠加该信号自己的 `SignalStyle::offsetTime`**(采样画在 `t + offsetTime` 处)。三处入口:`TabConfig::ComputeVisibleTimeBounds`、`PlotView::CollectFitContributions`(`minTime/maxTime` 与 `pointTime`)、`PlotView::FitTimeToLane` |
| 2 | 派生信号把 `offsetTime` 按 `time[i] - offsetTime` **重采样烘进采样值**(2026-10-21 的"烘值"规则) | **不再烘值**:时间偏移一律写进**新信号的样式**(`derivedStyle->offsetTime`)。理由:烘值有损 —— 平移出时间轴范围的那一段只能写 NaN,偏移 ≥ 数据跨度时整条信号全 NaN(= 空信号);而且即便偏移很小,两端也会出现与预览不符的空洞 |
### 2. 根因与验证
* **①** `TimeBounds()` → `ComputeVisibleTimeBounds()` 只统计 `axis.minTime/maxTime`,
`FitContribution::{minTime,maxTime,pointTime}` 同样是源时刻 ⇒ 偏移后取景仍停留在原始时间范围,
曲线整体跑到视界之外。
* **②** `BuildDerivedSignal` 的 2c 步把偏移烘进采样值。派生信号与源信号**共用同一条文件时间轴**,
无法用采样值表达超出轴范围的平移 ⇒ 只能丢成 NaN。
预览曲线本来就用源样式的 `offsetTime` 绘制(`PaintDerivedCurve`:`TimeToX(time[index] + style->offsetTime)`),
所以**把偏移留在派生信号的样式上,显示与预览逐字节一致,且数据一点不丢**。
* 新探针 `.preflight/derivecheck`(16/16):无偏移的均值滤波/弱峰调整有数据且范围不越界;
单信号 +10% 偏移后 `FitAll` 视界 = `[min+off, max+off]`;偏移 10% 与 200% 时派生信号都保持
`finite == values.size()`,且样式上带着偏移。
* 回归:`uicheck` 69、`lbcheck` 21、`lbresid` 5、`lbzorder` 4、`lbcovr` 7、`lbpanel` 10、
`derivecheck` 16 全过;`rendercheck` 8 场景哈希**与上一轮逐一相同** + FUNC 7/7;零 warning;
产物仍是 **788,992 B**。
### 3. 副作用(已知、可接受)
派生信号如果源自一个"带时间轴偏移"的信号,它的「调整」面板里「时间轴」会显示该偏移值
(不再像以前那样强行"默认值")。这是**刻意**的:时间偏移无法无损地写进采样值,
把偏移诚实显示出来比"面板是默认值但数据被裁掉/变空"要好。
## 2026-10-29 第十五轮(本机):主题配色 + 信号右键菜单精简/克隆 + 布局边界层级根治 + 单点吸附
> 用户本轮五件事:① 派生信号"时间轴偏移保留、其它调整烘值"维持现状(已确认无需改动);
> ② 信号名右键菜单精简为「克隆该信号 / 仅显示该信号」;③ 设置菜单新增「主题配色」二级菜单(4 套);
> ④ 修复布局边界在浮动面板上的**闪烁**与**取值胶囊文字穿透**,并审计网格内各元素的层级;
> ⑤ 修复**单数据点信号**的光标吸附。
### 1. ⚠️ 布局边界层级:根因是"文本层号写死"
**症状**:中键平移视界时浮动面板上的边界闪烁;光标取值胶囊的**文字**框穿透到浮动面板之上。
**根因(两条,都很隐蔽)**
1. 文本是在**绘制过程中**由 `UiTextRectSink` 收集的,而宿主把所有文本都记在**固定层 2**;
组件几何层却是"组件表下标"(PlotView = 0)。于是
* 面板层(= PlotView 层 + 1 = 1)**低于**文本层 2 ⇒ 遮挡矩形挖不到文本 ⇒ 文字画在面板表面;
* 面板自己的文字也被算在层 2,与面板几何层不一致。
2. 浮动面板"先画进像素图层、命中缓存就贴图":贴图那一帧**不画字** ⇒ 文本框凭空消失
⇒ 观感就是"面板上的边界闪烁"。
**修法(三条)**
1. `UiTextRectSink` 增加 `OnTextLayer(int)` / `TextLayer()`,并新增自由函数
`UiSetTextLayer(int)` / `UiGetTextLayer()`;`UiBoxTable` 实现为改 `m_layer`。
宿主 `Render` 在绘制每个组件前 `UiSetTextLayer(ComponentLayer(&component))`,
浮层用 `OverlayLayer(1/2)` —— **与 `PaintLayoutBounds` 分配几何层号完全同源**。
2. `PlotView::BuildBoxes` / `paintOverlays` 里把层号继续细分(见下"层号约定")。
顺手修掉一个真缺陷:`BuildBoxes` 原来用 `PushLayer()` 却**从不复位**,
会把宿主后面所有组件的层号整体推高。
3. 面板像素图层新增 `PanelSlot::textRects`:渲染面板时用**转发型收集器**
(`PanelTextCollector`,一边转发给外层、一边存一份)把文本框存进 slot,
命中缓存贴图时**回放**给外层收集器 ⇒ 几何与是否命中缓存无关。
**层号约定(现行,务必按此)**
```
layer = 组件表下标 × 3 + { 0 = 基层, 1 = 组件内部不透明浮层, 2 = 浮层之上的叠加层 }
PlotView:0 = 网格/图例/Y轴标尺/GX/时间轴/光标读数与取值胶囊
1 = 实验性功能浮动面板(同时登记为 occluder)
2 = 滚动条 / 「回到最上方」/ 内联输入框(绘制在面板之上)
浮层:OverlayLayer(0)=分隔条、1=菜单、2=拾色器
```
### 2. 单数据点信号吸附失效(回归)
`MinorCellSparse` 在原"把判定范围收窄到数据实际时间跨度"的分支里有
`if (!(dataMax > dataMin)) return false;` —— **只有一个有效采样点时 `dataMax == dataMin`**,
于是被判定成"不稀疏"⇒ 不吸附。
改为只在 `dataMax < dataMin` 时放弃;相等时继续走"那一个小格"的路径:
`firstCell == lastCell`、`need = 5`,而 `finiteCount == 1 < 5` ⇒ 判为稀疏 ⇒ 吸附恢复
(并且 `LocalSampleSpacing` 对 `finiteCount <= 1` 返回 0 = 不额外收窄半径,行为自洽)。
### 3. 信号右键菜单精简 + 「克隆该信号」
* 菜单只剩:标题(信号名)、**克隆该信号**、**仅显示该信号**(用户 2026-10-29 要求)。
删掉的条目:显示/隐藏该信号、显示全部、隐藏全部、预设色块行、样式(线段/散点/点线)
—— 它们都已被下方**样式自定义区**与**分组右键菜单**覆盖。
同时删除 `CMD_SIGNAL_TOGGLE/SHOW_ALL/HIDE_ALL`、`CMD_COLOR_BASE`、`CMD_MODE_BASE`
及它们的分支(死代码)。
* 新增 `CMD_SIGNAL_CLONE`(post `WM_APP + 5`,注意 `WM_APP+3` 已被
`kMessagePlotDerivedReady`、`+4` 已被 `kMessagePlotLowMemory` 占用)→
`Application::CloneSignal(ref)`:把「调整」(系数/绝对值/镜像/Y 偏移)烘进采样值、
**时间轴偏移保留在样式上**,名称前置 `*`,插入到源信号**正下方**。
克隆**保留原外观**(颜色/透明度/线宽/绘制样式),与"构建为新信号"(外观走调色板)不同。
* 抽出 `Application::InsertSignalBelowSource(...)`:插入 → 各标签页下标重映射 →
勾选 + 把新网格挪到源网格下方(**不做** `SyncTabsToDataSet`,让调用方先改外观)。
### 4. 主题配色(设置菜单 → 主题配色 → 二级菜单,带 √)
* `ThemeKind{LIGHT, SOLARIZED_LIGHT, DARK, SOLARIZED_DARK}`(顺序即菜单顺序);
`ThemeFor(kind)` / `ThemeName(kind)`。**默认仍是 Solarised Light**(用户要求默认不变)。
* 新增 `CurrentTheme()` / `SetCurrentTheme(const Theme*)`:宿主把自己的 `m_theme` 实例地址
交给全局指针 ⇒ 切换主题只改 `m_theme` 内容,所有持有 `const Theme*` 的组件自动跟随;
`UiControls` 里那些**没有 Theme 形参**的原语(`UiDrawCheckBox` / 色块描边等)改用
`CurrentTheme()`(原来硬编码 `SolarizedLight()`,换主题不生效)。
* `Application::ApplyTheme(kind)`:改 `m_theme` → `m_plot.OnThemeChanged()` +
`m_panel.OnThemeChanged()`(作废场景/宽基底/装饰/光晕/面板图层与行位图缓存)→ 整窗重绘。
⚠️ 只作废场景缓存是不够的:**宽基底图层、装饰层、面板层、信号列表行位图**里都烘着主题色。
* 菜单:`MenuItemKind::SUBMENU` + 4 个 `NORMAL` 子项(`keepOpen = true`);
选中后宿主用**新接口 `PopupMenu::UpdateSubmenuItemLabel()`** 回写勾选(子菜单是
`parent.submenu` 的快照,只改 `m_items` 不重建布局不会生效)。
同时让 `PopupMenu::OnLButtonUp` 的子菜单分支尊重 `item.keepOpen`
(原来无条件 `Close()`)⇒ 可以连续预览几种配色,勾选会实时挪动。
### 5. 审计:删掉名实不符的 `LAYOUT_BOUNDS` 开关
`Makefile` 里 `LAYOUT_BOUNDS` / `JDI_LAYOUT_BOUNDS` **从未被任何 `#if` 引用**,
是个只声明不生效的死开关(`LAYOUT_BOUNDS=0` 产物字节数完全相同)。
已删除并在 Makefile 注明原因:「显示布局边界」现在是**正式功能**,
真要可剔除需要给每个组件的 `BuildBoxes` 重载都加 `#if`,代价远大于十几 KB。
### 6. 验证
新增两支离屏探针(不占鼠标/键盘/屏幕):
```
.preflight/round15check 28/28
单点吸附 7 条(含"远处不吸附")、面板层级 5 条、面板文本框跨帧/平移稳定 2 条、克隆 14 条
.preflight/themecheck 17/17
4 套主题可读性/可区分、默认= Solarised Light、逐主题换色且无旧底色残留、
切回默认逐像素还原(diff=0)、子菜单 4 项/顺序/唯一勾选/命令切换/菜单保持打开
```
回归全绿:`uicheck` 69、`lbcheck` 21、`lbresid` 5、`lbzorder` 4、`lbcovr` 7、`lbpanel` 10、
`derivecheck` 16;`rendercheck` 8 场景哈希**与上一轮逐一相同** + FUNC 7/7;release/debug 零 warning;
产物 **791,552 B**(单一 exe,未拆 DLL,未改 `-Oz`,离 1MB 预警线约 250 KB)。
### 7. 探针坑(新增)
* 验证"取值胶囊穿透"必须挑一个**该时刻真的有采样**的网格(单数据点信号的网格上没有胶囊);
并把面板位置改到胶囊上方(`lane.freeMarkerPanel.x/y`)。
* `lbpanel` 的 "layer 1 只能是面板内容" 断言会随层号约定变化而失效:现在滚动条/回到最上方
在 `base + 2`,因此各组件必须按 `kLayersPerComponent = 3` 取层。