Files
Just-DataInspector/Documents/agent_mem.md
T
DeepSeek V4.1 Flash 1ddbc37b78 平移不再阻塞前台刷新;弱峰增强逐信号参数;浮动面板加入层序
一、平移:前台刷新线程永不阻塞(解决"按住键拖拽时质量下降/断线/周期性卡顿")
- RollWideBuffer 不再整块重画:逐网格整数像素平移 + 只补细条;内容指纹过期或锚点离视界
  过远的网格只登记到 m_layerRebuildPending 并仍按现有像素平移(位置照样跟随视界)。
- 新增 RenderPendingLaneRebuilds(budget):Paint 每帧最多重画 1 个网格,其余留到后续帧;
  队列非空时 PostMessage(kMessagePlotDerivedReady) 请宿主再送一帧,直到补齐。
- BlitWideBuffer 改为局部贴图:把 BitBlt 夹到图层可用区间,图层没覆盖到的部分留空由底色
  填充(旧实现直接跳过整块,导致网格瞬间变空 = "断线")。
- "结算"(整幅全质量精确帧)触发条件收紧为三者同时成立:内容与视界同上一条快路径帧、
  m_dragMode == NONE、全部图层就绪。拖拽期间不再结算,消除"宽基底快路径 / 整幅重绘(关AA)"
  每两帧振荡造成的质量下降与周期性卡顿。
- 新增 LaneLayerReady(i) / LanesAllReadyForView();原 LayersReadyForView() 更名
  LayersStructurallyReady()(只看结构,用于"跨度/布局变化必须整批重建一次")。
- 实测(.preflight/panperf,VERY_BIG_DATA 5 网格深放大,每帧平移 60px):平移 40 帧
  中位 6.2ms / p90 7.2ms / max 7.6ms;按住键且视界停在边界 12 帧中位 0.29ms(无整幅重绘)。

二、弱峰增强:改为逐信号参数(对齐均值滤波)
- Lane::WeakPeakItem 由 using = MeanFilterItem 改为独立结构体
  { ref, levelPercent(0-200, 默认 150), iterations(1-10, 默认 1) };删除网格级
  Lane::weakPeakLevel / weakPeakIterations。
- 面板改为与均值滤波完全同构:每个被勾选信号一行"名称 + 复选框",第二排两个输入框
  (增强等级 % / 迭代次数 N),底部"构建为新信号";删除网格级两个滑块及其常量、
  WeakPeakSliderHit、PlotDragMode::PEAK_LEVEL/PEAK_ITERATIONS、m_peakDragLane、
  EdgePanelLayout::slider/sliderIterations。
- 新增 PlotEditTarget::PEAK_LEVEL_PERCENT / PEAK_ITERATIONS,EndEdit 中分别夹到 0-200 / 1-10。
- 指纹/快照/预览曲线/光标胶囊/构建新信号全部改为逐信号参数(同一网格内不同信号可用不同强度,
  且"预览"与"构建"逐位一致);WeakPeakItem 不再是 MeanFilterItem,purgeRefs 改为泛型 lambda。

三、浮动面板:层序(z-order)与命中修复
- 新增 m_panelOrder(队尾 = 最上层):SyncPanelOrder 保留已有序、新面板追加到最上层;
  TopPanelAtPoint 从最上层往下取第一个命中;BringPanelToFront 在点击面板时把它置顶。
- 绘制改为按 m_panelOrder 自下而上;PanelGlobalKey 混合层序(层序变化即整层重画);
  QueryCursor / OnMouseMove 的"是否在面板上"也走 TopPanelAtPoint。
- 修复"上层面板的 ✕ / 标题栏被下层参数输入框抢走,导致上层面板关不掉/拖不动"。

四、测试
- uitest 新增 [AG]:层预算(每帧只重画 1 个网格、剩余排队、最终补齐并全部就绪)、
  局部贴图(只覆盖视界一半时左侧被贴、右侧保持哨兵色)、面板层序(上层 ✕ 命中上层、
  下层不受影响、点击下层置顶、TopPanelAtPoint 随之改变、新开面板在最上层)。
- [X] 改为逐信号参数(含两个输入框就地编辑:写入该信号自己的条目、越界夹取),
  移除已不存在的滑块断言。
- 新增离屏探针 .preflight/panperf(平移帧耗时 + 按住键静止帧耗时)。
- make all 零警告;uitest 388/388 通过;panperf PASS;layercheck 保持像素一致。
2026-10-12 12:06:42 +08:00

2487 lines
245 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` 结束时会打印总量。
---
## 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 通过**。 |
---
## 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 线程内同步完成**(`Paint` 全程持有 `m_renderMutex`)。**绝不要把图层渲染搬到后台线程**:它会临时改写 `m_area`/`m_plotRect`/`Tab().ViewMin()/ViewMax()`/各网格矩形,而这些状态被 UI 线程的消息处理**无锁读/写**(平移读视界、命中测试读网格矩形),后台的"保存→改写→恢复"会**覆盖用户正在进行的平移**(视界弹回)并让命中测试错位 —— 这就是 2026-10-12 移除后台图层线程的原因(详见 §12 本轮)。
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. **不要为波形构建 / 绘制引入多线程**;已落地的多线程只有派生曲线线程池(`m_curveWorkers`)。任何"移动 / 释放采样"的操作前必须 `PauseDerivedAsync()`。
15. MinGW 的 `swprintf` 按 C99:宽格式串里的 `%s` = **窄**字符串,宽字符串必须用 `%ls`(否则 "KB" 会被截成 "K")。
16. 源文件名必须**全局唯一**(Makefile 按 basename 去重 + vpath 查找);新增目录要同时登记进 `CXX_INCLUDES_PATHS` / `CXX_SOURCES_PATHS`。
### 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. 起源设备详细工程记忆(原文附录)
> 以下为起源设备(本机)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 次视界覆盖”。