Files
Just-DataInspector/Documents/agent_mem.md
T
DeepSeek V4.1 Flash e988da7963 宽缓冲改为滚动式:消除平移过程中的周期性重建尖峰
视界每帧移动 d 像素时,把缓冲内容整体自贴图平移 round(d) 像素并只补画新暴露的 |round(d)| 像素宽细条(细条渲染复用整条宽窗口的映射,故网格/波形 x 坐标与刻度密度均正确)。用锚点把内容相对时间轴的亚像素偏移限制在半个像素内;缓冲只覆盖绘图区,时间轴由前台每帧绘制。实测 VERY_BIG_DATA 全信号、视界 [40.1756,40.1885]s、5 网格:持续平移拖动约 6ms/帧、长距最大 10.5ms(原为每屏一次 33ms 尖峰);弱波增强滑块 6ms、样式 8ms;静止帧与 HEAD 逐像素 0 差异。
2026-10-11 23:08:46 +08:00

359 lines
44 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 的一次尖峰);持续拖动过程中无尖峰。要消除这一次性启动尖峰,需把基底渲染做成可后台的独立渲染器(同上,较大重构)。非拖拽视界变化仍整幅重绘。
---
## 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 差异。残留:每次开始拖拽的首帧需新建缓冲(一次启动尖峰)。 |