# 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 [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`)追加进 `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_.exe 1 ` 导出确定性 PNG;`.preflight/perf/compare.ps1` 用 `LockBits`+`Marshal.Copy` 逐字节比对。 - **踩坑**:比对若用 `[System.Drawing.Bitmap]::GetPixel` 且两侧位图**尺寸不一致或文件缺失**,会抛 `调用"GetPixel"时发生异常:"参数无效"`。根因通常是"旧版本"的 harness 链接失败(例如它引用了新版本才有的符号)导致对照图没生成。**务必**:①先确认两侧 PNG 都存在且尺寸一致;②用 `LockBits` 而非逐点 `GetPixel`(后者对上兆像素级图片极慢)。 - **实测结论**(`709cbe4` 的"可见窗口预览" vs `389b4ec` 的"整段预览",同 harness 同数据):全部**全质量帧**(均值滤波/弱波增强 × FitAll/2.5s/103ms 三个缩放)**逐像素 0 差异** —— 证明"改为整段计算"未带来任何显示回归,预览与"构建为新信号"一致;仅**拖拽中途**帧有 0.09% 差异(快速帧关抗锯齿 + 装饰层贴回的预期差异)。 ### 9.9 深度定位:样式/参数滑块的卡顿根因与修复(探针 + 分支计时) - **用户复现**:`VERY_BIG_DATA` 加载全部信号 → 选区放大到视界 `[40.1756, 40.1885]s`(Δt≈12.9ms,≈2 采样/像素)→ 拖左/中键平移、拖透明度/粗细/自定义颜色/拾色、拖弱波增强参数滑块 → 极卡;但**拖时间光标很流畅**(光标只改叠加层、命中静态场景缓存)。 - **定位方法**(临时探针,`JDI_PERF_PROBE` 门控,事后已移除):在 `.preflight/perf` 的离屏 harness 中**精确复现该视界**(`mode=2`),给 `PlotView::Paint` 的每个分支(缓存贴图/样式补丁/整幅重绘/网格重排)与 `PaintLane` 的每个子阶段(网格线/波形/派生曲线/Y 轴刻度/图例/包络带 `FillPath`)加计时。 - **实测拆解**(本机当时负载约 4×,下列为负载下的值): | 场景 | 整帧 | 其中包络带 `FillPath` | | --- | --- | --- | | 平移(整幅重绘,**抗锯齿开**) | 161ms | **143ms(5 网格,≈29ms/条)** | | 平移拖动(快速帧,**抗锯齿关**) | 34ms | 27ms(5 网格,≈5.4ms/条) | | 透明度/粗细/颜色(样式补丁,**抗锯齿开**) | **77ms** | **72ms(1 网格)** | | 弱波增强参数滑块 | 96–176ms | (含整幅重绘放大) | - **两个真正的根因**: 1. **样式补丁用了抗锯齿**:拖透明度/粗细/自定义颜色属于"样式变化 → 局部补丁",但它**不在任何拖拽态**,于是 `m_fastFrame=false` → 补丁用**抗锯齿**重画该网格的包络带(≈72ms/网格)。这是"样式滑块极卡"的直接原因。 2. **后台派生曲线发布时整幅作废**:拖弱波增强/均值滤波参数滑块时,后台每算完一条就 `InvalidateSceneCache()` → 下一步退化成**整幅重绘**(负载下 150ms+),而不只是补丁。 - **修复(3 处,均只影响拖拽帧;静止/结算帧逐像素不变)**: - **样式补丁强制快速帧**:进入补丁分支时设 `m_fastFrame = true`(关抗锯齿)并置 `m_sceneDegraded = true` → 补丁 ≈72ms → **≈14ms**;参数稳定后的下一帧自动补一帧全质量。 - **拖拽中不整幅作废**:后台曲线发布时,若 `m_dragMode != NONE`(拖拽中)只置 `m_sceneDegraded`,由每帧补丁直接取到最新曲线;仅静止时才整幅作废。弱波增强滑块 ≈96–176ms → **≈25ms**。 - **交互期间省略浮动面板投影**:面板的两张大面积半透明圆角投影在快速帧跳过,减少面板重绘开销。 - **结果(同一负载机器)**:透明度/粗细/颜色 **77ms → 16ms**;弱波增强滑块 **96–176ms → 26ms**;平移拖动 **34ms**(快速帧,空闲机 ≈8ms)。全质量/结算帧与修复前**逐像素 0 差异**(`LockBits` 对照验证)。 - **仍存在的固有下限**:整幅重绘中的**包络带 `FillPath`** 本身是 O(像素列数) 的 GDI+ 抗锯齿填充(≈29ms/网格/负载)。它只在"整幅重绘"或"结算帧"出现(平移快速帧已关抗锯齿 ≈5.4ms/网格)。若要在密集包络带上也关抗锯齿可再快 5×,但会轻微改变边缘观感,**需用户确认后再动**。 ### 9.10 平移「宽缓冲区」:让平移的时间复杂度降为 O(1)(用户方案落地) - **用户的判断(正确)**:平移视界时**画面内容并不改变,只是横向平移**;如果只是重画得快、而不复用已画好的像素,那么在"某些放大级别可见像素极多"时,重画必然卡住消息循环,交互就必然掉帧。正确做法是**复用已绘制的像素**,让前台始终只做 O(1) 的取用。 - **对现有代码的核对**:用户判断与仓库现状**基本一致** —— 之前的历次优化都是"加速绘制"(包络带 `FillPath`、装饰层、缓存),而**没有把"绘制"这一任务本身从交互帧里拆出去**(后台线程只算了派生曲线的数值,没有做波形光栅化)。因此一遇到"可见像素特别多"的放大级别(如 `[40.1756, 40.1885]s`),每帧重画就再次成为瓶颈。 - **可行性结论**:用户的"每网格双缓冲 + 后台填充 + 前台只贴已就绪缓冲"方向正确。要点补充:① GDI/GDI+ 绘制**可以在后台线程**进行(每个线程用**各自**的 `Bitmap`/`HDC`/`Graphics` 即可),所以后台光栅化可行;② 但本仓库的 `PaintLane` 会读写大量成员缓存(折线/曲线/图例命中/装饰矩形),直接搬到后台线程需要一次较大重构才能保证线程安全。③ 因此本次采用一个**风险更低、收益相同**的落地方式(见下)。 - **本次实现(平移 O(1))**: - **宽缓冲区**:以当前视界为中心,向左右各多渲染**半个视界宽**,存成一张宽为 `2×视界宽` 的离屏位图(`m_wideDc`)。它用与正常绘制**完全相同**的 `PaintLane`/`PaintTimeAxis`(临时把横向映射"加宽"后渲染),因此像素与正常绘制一致;仅**跳过图例与 Y 轴刻度**(二者锚定网格左缘、随视界移动,改由已有的**装饰层缓存**贴回)。 - **平移 = 一次 BitBlt**:视界宽度不变、只是横向移动时,前台从宽缓冲区**截取对应横向子矩形**贴到后备缓冲区即可(O(1)),**完全不重画波形**。无论放大到多深,平移都与全局显示一样流畅。 - **边界重建**:当视界移出宽缓冲范围(或跨度/内容变化)时,才以新视界为中心**重建一次**(约占整幅重绘 2 倍,但只在每平移约半屏时发生一次)。 - **实测**(`VERY_BIG_DATA` 全信号,视界 `[40.1756, 40.1885]s`,5 网格;单位 ms/帧,负载机): | 场景 | 修复前 | 修复后 | | --- | --- | --- | | 中键平移拖动(短距) | 34ms | **1.3ms** | | 中键平移拖动(长距,含边界重建) | 34ms | **1.7ms**(峰值 50ms 为一次边界重建) | | 透明度/粗细/颜色 | 16ms | 8ms | | 弱波增强滑块 | 26–176ms | 约 15ms(空闲)/ 55ms(负载) | - **显示效果**:**静止/结算帧与修复前逐像素 0 差异**(`LockBits` 对照);平移拖动帧为快速帧质量(关闭抗锯齿,与既有"拖动时低分辨率、松手补全质量"策略一致),松手后立即恢复全质量。 - **说明**:宽缓冲区重建目前在前台进行(每平移约半屏一次);若日后要连这一次重建也移出交互帧,需把 `PaintLane` 的成员缓存重构为可复制的渲染上下文再搬后台(较大改动)。弱波增强参数滑块仍有一次"重绘受影响网格 + 浮动面板"的开销(负载下约 55ms),是本轮未彻底消除的残留。 ### 9.11 把「宽缓冲区」推广为**基底复用**:平移 + 拖参数滑块都降为 O(1) 承接 9.10,进一步把「宽缓冲区」从"只用于平移"推广为**通用基底复用**,彻底解决拖派生参数滑块的问题: - **抽取派生曲线绘制**:把 `PaintLane` 里叠加派生预览曲线(均值滤波/中心轴/弱波增强)的"第二遍"抽成 `PaintLaneDerivedOverlays`。宽缓冲区渲染时**跳过派生曲线**,因此宽缓冲只存**基底**(网格/波形/时间轴,不含派生曲线与图例/Y 轴刻度)。 - **基底键与派生参数解耦**:宽缓冲的校验键改为 `SceneContentHash(true, false)`(含外观样式、**不含派生参数**)→ 拖弱波增强等级/迭代次数滑块**不会让基底失效**,可直接复用基底像素。 - **统一前台路径**:判定"网格结构未变(几何指纹一致)且逐信号外观样式未变"(即只有**视图**或**派生参数**变化)时 → 贴宽缓冲基底像素 + **只重画派生曲线叠加层** + 装饰层 → 前台 O(1),不重画波形。 - 外观样式(颜色/粗细/透明度/模式)变化仍走"样式补丁"(局部重画受影响网格)。 - **实测**(`VERY_BIG_DATA` 全信号,视界 `[40.1756, 40.1885]s`,5 网格;负载机 ms/帧): | 场景 | 9.10 后 | 本次后 | | --- | --- | --- | | 中键平移拖动 | 1.3ms | **1.3ms** | | 弱波增强滑块 | 55ms | **5.2ms** | | 透明度/粗细/颜色 | 8ms | 8ms | - **显示效果**:静止/结算帧与 HEAD **逐像素 0 差异**(`LockBits` 验证)。拖拽/拖参数帧为快速帧质量(关抗锯齿),松手后恢复全质量。 - **仍存在的残留**(如实记录): 1. **宽缓冲区重建**在视界移出缓冲范围(约每平移 1 屏一次)时于**前台**进行,负载下约 30–50ms 的**一次**尖峰。彻底消除需把"基底渲染"做成**不依赖成员状态的独立渲染器**再搬后台线程(`PaintLane` 会写成员缓存,直接后台化有数据竞争,属较大重构)。 2. **非拖拽的视界变化**(键盘方向键 / 工具栏)仍走整幅重绘(约 125ms);仅**鼠标左/中键拖动**走宽缓冲快路径。若需让键盘平移也快,需允许非拖拽帧复用"整数像素对齐"的宽缓冲(当前为避免 ≤0.5px 亚像素偏差而未启用)。 ### 9.12 宽缓冲改为「滚动」:消除周期性重建尖峰(持续拖动恒 ≥60fps) 9.11 的残留 1(移出边界时整幅重建 2×视界,负载下约 30–50ms 的一次尖峰)已消除: - **滚动式缓冲**:视界每帧移动 d 像素时,把缓冲内容**整体自贴图平移 `round(d)` 像素**,再**只补画新暴露的 `|round(d)|` 像素宽细条**。因此**每帧代价恒为 O(细条)**,不再有"移出边界就整幅重建"的周期性大尖峰。 - **细条渲染复用整条宽窗口的映射**:`RenderWideStrip` 令 `m_area` 只覆盖细条(GDI/GDI+ 裁剪落在细条),而 `m_plotRect` 仍是完整宽窗口 → 网格线与波形 x 坐标正确、刻度密度与主视界一致;仅新暴露的细条被重画。 - **亚像素误差有界**:用锚点 `m_wideAnchorMin` 记"视界左端当前对应缓冲中的时间",每帧按 `round((viewMin - anchor)*scale)` 平移并同步推进锚点,使"内容相对时间轴的偏移"始终 ≤0.5 像素。 - **缓冲只覆盖绘图区**(不含底部时间轴):时间轴由前台每帧按当前视图绘制,避免滚动细条把时间轴文字切成残字。 - **实测**(`VERY_BIG_DATA` 全信号,视界 `[40.1756, 40.1885]s`,5 网格): | 场景 | 9.11 | 本次 | | --- | --- | --- | | 中键平移拖动(短距) | 1.3ms(含每屏一次 33ms 尖峰) | **约 6ms/帧,无尖峰** | | 中键平移拖动(长距 975px) | 1.7ms(最大 33ms) | **约 6ms/帧,最大 10.5ms** | | 弱波增强滑块 | 5.2ms | 6ms | | 透明度/粗细/颜色 | 8ms | 8ms | - **显示效果**:滚动平移帧截图核对无缝隙/无残字;**静止/结算帧与 HEAD 逐像素 0 差异**(`LockBits` 验证)。 - **剩余残留**:**每次"开始一次拖拽"的第一帧**需新建缓冲(2×视界基底,负载下约 38ms 的一次尖峰);持续拖动过程中无尖峰。要消除这一次性启动尖峰,需把基底渲染做成可后台的独立渲染器(同上,较大重构)。非拖拽视界变化仍整幅重绘。 ### 9.13 消除"开始拖拽"的首帧尖峰:用场景缓存初始化基底 + 启用预览功能时预建基底 9.12 的残留(首帧需从零新建 2×视界基底,负载下约 38ms)已消除: - **平移首帧用「上一帧静态场景缓存」初始化宽基底**(`InitWideBufferFromScene`):平移只是整体横向移动,场景缓存里的内容(含派生曲线/图例)**依然正确**,因此把它直接贴进宽基底中心即可,再滚动到当前视界 —— 首帧只需一次 BitBlt + 一条细条,**不再从零重画 2×视界**。仅当"派生预览曲线参数未变"时适用(拖这些参数时派生曲线会变,须从零重建基底)。 - **启用带预览的实验性功能时预建基底**:在 `SetLaneWeakBoost`/`SetLaneMeanFilter`/`SetLaneEdgeCenterCount(…, true)` 里预建宽基底,使**首次拖动这些参数滑块**时无需从零重建(避免一次性尖峰)。 - **实测**(`VERY_BIG_DATA` 全信号,视界 `[40.1756, 40.1885]s`,5 网格,负载机 ms/帧): | 场景 | 9.12 | 本次 | | --- | --- | --- | | 中键平移拖动(短距) | 最大 35ms(首帧重建) | **最大 8.5ms** | | 中键平移拖动(长距) | 最大 10.5ms | **最大 7.4ms** | | 弱波增强滑块 | 最大 37.6ms(首帧重建) | **最大 10.8ms** | | 透明度/粗细/颜色 | 8–16ms | 8–15ms | - **结论**:用户报告的全部**鼠标**交互(左/中键拖动平移、样式滑块/拾色、弱波增强参数滑块)在该放大区间**全程 ≥60fps、无尖峰**;静止/结算帧与 HEAD **逐像素 0 差异**。 - **当时仍存在的残留**:**非拖拽的视界变化**(键盘方向键 / 工具栏,非用户报告的鼠标场景)仍走整幅重绘(约 125ms)—— 因其没有"拖拽结束"事件可用来触发一次精确的全质量重绘,若走宽缓冲快路径会留下 ≤0.5px 的亚像素偏差且无法"结算"回精确帧,故当时保持整幅重绘以保证像素精确。**该残留已在 9.14 中解决。** --- ### 9.14 非拖拽视界变化(键盘 / 工具栏)也走宽基底快路径 + "结算"帧保证像素精确 9.13 的残留(非拖拽视界变化 ≈125ms / 8fps)已消除: - **宽基底判定去掉"必须在拖拽中"**:只要"网格几何指纹 + 逐信号外观样式 + 装饰层"未变(派生曲线参数变化也算),任何**视界变化**都可用宽基底,前台只贴图 + 画时间轴/派生叠加/装饰层 —— 键盘方向键、工具栏缩放平移帧降到 **≈8ms**。 - **"结算"帧(保证最终像素精确)**:宽基底是整数像素平移,实际视界位置带**≤0.5px** 亚像素偏差。因此在快路径帧里把"内容指纹 ⊕ 视界"揉成一个 `wideKey`;**若本帧的 `wideKey` 与上一条快路径帧相同 → 判定"视界已停止变化" → 放弃快路径,落到整幅重绘**(一次全质量精确帧),并清空 `m_wideLastValid`。该帧同时刷新静态场景缓存,之后所有帧命中缓存(≈1ms),不会反复重绘。 - **分支顺序有硬约束**:静态场景贴图 → **宽基底** → 样式补丁 → 整幅重绘。 * 宽基底必须在**静态场景贴图之后**:视界与样式都没变时(鼠标悬停移动)必须命中场景贴图,否则每两帧就会触发一次"结算整幅重绘",悬停反而变卡。 * 宽基底必须在**样式补丁之前**:派生预览曲线参数变化(拖弱波增强 / 均值滤波滑块)走宽基底比"逐网格补丁"更快且质量更高(补丁会关抗锯齿)。 - **宽基底必须与"同一视界的整幅渲染"同源**(否则平移后网格/锯齿与前台不一致):基底渲染时**强制全质量抗锯齿**(不受拖拽快速帧影响),且网格竖向刻度沿用**前台视界跨度**而非 2× 的宽窗口跨度(后者会让竖网格线减半)。 - **实测**(`VERY_BIG_DATA`,视界 `[40.1756, 40.1885]s`,ms/帧): | 场景 | 9.13 | 本次 | | --- | --- | --- | | **非拖拽视界变化(键盘/工具栏)** | 最大 124.6ms(≈8fps) | **最大 8.6ms** | | 中键平移拖动(短/长距) | 8.5 / 7.4ms | 9.3 / 14.6ms | | 透明度 / 颜色滑块 | 8–16ms | 10.6 / 12.5ms | | 弱波增强滑块 | 最大 10.8ms | **最大 7.5ms** | | 粗细滑块 | 8–15ms | 40 帧中仅**首帧** 127ms(其余 7.7ms) | - **粗细滑块那一帧 127ms 是"设计内"的**:样式连续变化期间走"样式补丁 + 已降级标记"(快),一旦样式**停止变化**(含"回到与缓存相同的值")就必须补一帧全质量 —— 该帧必然是整幅重绘。它是**用户松手后的唯一一帧**,不是持续卡顿。 - **保真度实测**(新增 `perf.exe … 5` 像素对比模式):快路径与"同一视界的整幅全质量渲染"对比 —— * 视界几乎不动(<0.01px,不滚动)时:`meanAbs = 0.68/255`(逐像素几乎一致,证明"底座 + 贴图"管线本身忠实)。 * 平移约 17px 时:`meanAbs ≈ 13.5/255`(差异集中在**密波形**上,是 ≤0.5px 亚像素平移的必然结果,肉眼不可辨);停止平移后的"结算"帧回到**像素精确**。 - **质量回退检查**:`mean_0/1/2`、`boost_0/1/2`、`panref` 七张静态/结算帧与 HEAD **字节完全一致**(SHA256 相同)。 - **意外收获**:平移**中途**帧比 HEAD 更干净 —— HEAD 把静态场景(含**图例 / Y 轴刻度**)直接贴进宽基底,平移后这些像素随之移动,出现**重复的网格标题与刻度重影**(见对比 PNG);本次改动后同一场景不再出现重影。 --- ### 9.15 修复「缩放后平移出现拖影/残留/错误元素」与深放大区间仍然卡顿(本轮) #### 用户方案评估:与当前实现的差异 用户提出的目标是「每个网格各自维护双缓冲区 + 后台线程绘制 + 前台只做贴图/坐标偏移」。 与当前实现的差异与问题点: | 维度 | 当前实现 | 用户设想 | | --- | --- | --- | | 像素复用粒度 | **全局一张**「宽基底」+ 场景缓存 + 装饰缓存 + 样式补丁(4 套机制叠加) | **每网格**一张信号图层(双缓冲) | | 绘制线程 | 全部在 UI 线程;只有**派生预览曲线**在后台算 | 单网格绘制也搬到后台 | | 失效判定 | 多个指纹(内容/几何/装饰)各自为政,**容易漏项**(本轮事实上踩了坑) | 每网格一个状态指纹 | 问题点(本轮实证,是本次卡顿与拖影的**真正根因**): 1. **`Tab().LaneScroll()`(网格纵向滚动量)没有参与任何内容指纹。** 它只改变各网格的绘制位置,却被所有指纹忽略 → 一旦滚动网格(滚轮 / 上下方向键 / 滚动条), 平移宽缓冲区会把**旧滚动位置**的像素当成当前内容贴回(位置错位、拖影、错误元素); 同时因为指纹没变,"结算"逻辑误以为视界已停 → **每两帧就触发一次整幅重绘(约 125ms)**, 表现为 8fps 级卡顿。这是用户报告的**两个症状的共同根因**。 2. **`FindStyleOnlyLanes()` 只看 `SceneGeometryHash()`,而该指纹刻意不含视界。** 于是视界已经变了却仍走"只重画受影响网格"的局部补丁,把缓存里**旧视界的画面** (时间轴、其它网格、底色)留在屏上 → 拖影 / 残留。 3. **`InitWideBufferFromScene()` 把"上一帧的整幅画面"拷进宽基底**,而整幅画面里**含装饰** (Y 轴刻度胶囊 + 图例),宽基底却不含 → 装饰像素随平移一起移动,在网格左缘之外 留下一排旧图例/刻度(重影)。 4. **装饰缓存的两个不变量被打破**:贴回时用的是**当前帧重建的**矩形列表(命中缓存时根本不会重建 → 为空); 并用**当前帧的** `m_area` 原点去推算源偏移(两帧原点可能不同)→ 表现为装饰消失 / 黑块 / 整体错位。 #### 本轮修复 - `SceneContentHash()` 计入 `Tab().LaneScroll()`(根本修复:滚动网格不再复用旧位置像素, 也不再误触发"结算")。 - `FindStyleOnlyLanes()` 增加**视图几何逐项校验**(视界 / 滚动 / 布局 / 缩放 / 拖拽光晕)后才允许局部补丁。 - 宽基底**不再用"含装饰的整幅画面"初始化**;`RebuildWideBuffer()` 以 `m_renderWide` 跳过装饰, 保证基底永远不含装饰。 - 宽路径的装饰改为**按当前视界重画**(新增 `m_renderDecorOnly` 复用与整幅渲染**完全相同** 的 `PaintLane` 代码路径,只跳过网格线与波形)。 - 装饰缓存修好两个不变量:**每次真正绘制装饰的帧都立即重新捕获**(不再只在全质量帧捕获), 且捕获时**同时保存矩形列表与坐标原点**,贴回时用保存的这份、按绝对坐标写入。 - 装饰底板改为**不透明**(原来是 94% / 91%):装饰像素因此与时间视界无关,可安全跨视界复用; 半透明底板会把旧视界的波形混进缓存像素,平移时在底板内部留下残影。 - 新增离屏诊断模式(`.preflight/perf/perf_main.cpp` 的 `mode=6`):复现"加载 → 缩放 → 平移/键盘/滚轮" 全部交互,逐帧计时,并把"当前屏"与"从零整幅渲染"做**逐像素对比 + 差异包围盒**。 这是本轮定位问题的关键工具(否则只能靠猜测)。 #### 实测(`VERY_BIG_DATA.CSV`,视界 `[64.3948, 64.4153]s` ≈ **2.045ms 一大格**,5 网格,ms/帧) | 交互 | 修复前 | 修复后 | 像素差(对比从零整幅渲染) | | --- | --- | --- | --- | | 键盘左右方向键平移 | 11~19ms | **5.2ms** | 3705 通道(≤0.5px 亚像素偏移,见下) | | 键盘上下方向键滚动网格 | 0.9ms(但错位/偶发 125ms) | **0.5ms** | **0** | | 中键水平拖动 | 10~18ms | **5.2ms** | 357 通道 | | 中键**纵向**拖动 | 14~19ms | **6.0ms** | 72 通道 | | 左键水平拖动 | 9~17ms | **4.5ms** | 390 通道 | | 左键**纵向**拖动 | 15~19ms | **6.1ms** | 72 通道 | | 滚轮滚动网格 | 1.0ms | **0.6ms** | **0** | | 缩放后再平移 | 9~17ms | **3.7ms**(缩放帧 6.8ms) | 495 通道 | | 弱波增强预览 + 平移 | 15~18ms | **5.8ms** | 2264 通道(异步曲线发布时序) | - **修复前的纵向拖动会产生 2 万+ 通道的残留**(黑块 / 标签残缺);修复后降到 72 通道(仅亚像素量化)。 - 剩余的几百通道差异集中在**密网格/密波形**上,是"宽基底按整数像素平移(≤0.5px 亚像素误差)" 的必然结果,肉眼不可辨;视界停下后的"结算"帧会回到**像素精确**。 #### 「每网格双缓冲 + 后台绘制」架构(已落地,本轮同批提交) 把原来**全局一张**「宽缓冲区」重构为用户提出的方案: - **每网格一块「信号图层」**(`LaneLayer`):宽度 = 视界宽 ×3(左右各多画半屏),只含该网格基底 (底色 + 网格线 + 波形 + 派生曲线),**不含装饰**(Y 轴刻度 / 图例,它们锚定网格左缘、不随时间视界移动)。 - **双缓冲**:`m_layerFront`(前台只读)/ `m_layerBack`(后台填充)。后台画完整块后 `std::swap` 整体交换;**前台永远能拿到立即可用的像素**,后台正在绘制也不影响交互。 - **后台绘制**:`m_layerWorker` 线程(BELOW_NORMAL 优先级)按任务队列逐网格重画。 前台每帧只做:① 判断各网格图层是否就绪;② `BitsBlt` 把**就绪且跨度一致**的图层按坐标偏移贴到画面; ③ 画时间轴 / 派生叠加层 / 装饰 / 叠加层。**前台不再绘制任何波形**。 - **"未就绪就用旧数据应付"**:只要图层**跨度一致、能覆盖当前视界**,即使内容稍旧也照贴 (`LayersReadyForView()` 不比较内容指纹),内容由后台补画后交换 —— 于是样式/参数变化、 平移、滚动都不会退化成整幅重绘。只有"跨度变了 / 图层还没建"才落到整幅重绘一次以保证正确性。 - **不收敛保护**:若同一状态反复排任务仍与前台不一致(说明某参与指纹的状态没被正确还原), 超过 90 次即改为同步重建一次强行收敛,避免死循环。 - **前台优先**:后台在"前台最近 30ms 内绘制过"时主动让路(把任务放回队首,8ms 后再试), 避免与前台抢 `m_renderMutex` 反而更卡;交互停下后后台从容补画。 - **加锁顺序**:一律 `m_renderMutex` → `m_layerMutex`(后台与前台一致),避免 AB-BA 死锁。 - **渲染状态还原**:图层渲染会临时改写 `m_area` / `m_plotRect` / 视界 / 网格矩形, 结束后必须调用 `BuildLaneVisuals()` 重算 `m_laneContentHeight` 并还原 `LaneScroll` 与各网格幅值范围 —— 否则内容指纹与前台永远不一致 → 图层被判定"永远过期" → 死循环(本轮踩过这个坑)。 验证(离屏,`perf.exe ... 6`,视界 ≈ **2.045ms 一大格**): | 交互 | 帧平均 | 像素差(对比从零整幅渲染) | | --- | --- | --- | | 键盘左右方向键平移 | **3.5ms** | 3705 通道(≤0.5px 亚像素) | | 键盘上下方向键滚动网格 | 0.7ms | **0** | | 中键水平拖动 / 左键水平拖动 | 3.3 / 1.8ms | 357 / **0** 通道 | | 中键纵向 / 左键纵向拖动 | 5.7 / 5.5ms | 72 通道 | | 滚轮滚动网格 | 1.9ms | **0** | | 缩放后再平移 | 4.6ms(缩放帧 5.0ms) | 549 通道 | | 弱波增强预览 + 平移 | 8.7ms | 3590 通道(异步曲线发布时序) | - 「背景图层绘制次数」计数可直接证明**单网格基底的绘制确实发生在后台线程**:一轮诊断累计 **18~45 次**; 前台只在做位图搬运。 - 跨进程验证:`BackgroundLayerRenderCount()` 为公开只读接口,`perf.exe ... 6` 结束时会打印总量。 ### 9.16 修复「垂直中键拖拽后再平移,网格内容纵向跳变 / 绘制错误」(本轮) #### 症状与复现 `VERY_BIG_DATA.CSV`(5 信号全选)下,任意缩放等级都对同一根网格成立: 1. 对某网格(如最上层「编码器A相」)按中键**纵向**拖拽约半个网格高,让该网格幅值窗口上下平移; 2. **不松开**中键,只做**一点点水平位移**。 预期只有水平移动,实际该网格的波形**整体纵向错位**(最多 15px,视拖动落点而定),看起来像"纵向跳帧/抽搐"。 若改为"松开后再按下中键做同样的微小水平移动",同样复现。 #### 根因:横向补细条时静默重置了图层的纵向锚点 图层用 `LaneLayer::anchorYMax`(渲染时该网格的 `yMax`)描述**像素的纵向映射**;贴图偏移 `vOffset = round((anchorYMax - 当前 yMax) * pxPerValue)`。纵向拖动用 `kLayerRollQuantum = 16px` **量化**: `|vOffset| < 16px` 时不滚动,交给贴图偏移处理 —— 于是拖动停下时图层里会残留一个 `< 16px` 的纵向偏移, 而 `anchorYMax != 当前 yMax`。 问题出在 `RenderLaneLayerRegion` 结束时**无条件** `layer.anchorYMax = 当前 yMax`。该赋值对 「整块重画」「纵向补条(整宽)」成立(那两种情况整块像素都对齐到当前映射),但对 **「横向补细条(整高)」不成立**:横向滚动只改一条竖带,其余像素仍在**原锚点**上。于是第一次水平移动 (哪怕只有 3px)就把残余偏移**静默清零**(`vOffset` 变成 0),而像素并没有上移 → 整个网格的波形 相对正确位置纵向错位恰好等于那个残余量。 #### 修法 `RenderLaneLayerRegion` 新增形参 `double anchorYMax`(本次像素所对应的纵向锚点):内部以它作为幅值映射 基准(`yMin = anchorYMax - laneSpan`)渲染,并在结束时写入 `layer.anchorYMax = anchorYMax`。各调用点: - 整块重画 / 纵向补条 / 待重画补齐:传**当前** `yMax`(纵向补条传滚动后的 `layer.anchorYMax`,与已滚动像素同一映射); - **横向补细条**:传 `layer.anchorYMax`(**保持不变**),细条因此与其余像素纵向同源,残余偏移继续由贴图偏移承担。 #### 验证(离屏,`.preflight/perf/pancheck`,窗口 `SW_HIDE` + 直接注入消息,不动鼠标/键盘/屏幕) 探针逐帧读出图层内部状态(`anchorYMax` / 当前 `yMax` / `vOffset`),并把持按钮帧与 **释放后的整幅全质量帧**做纵向互相关,报告该网格的最佳纵向位移 `dy`。 | 场景(`VERY_BIG_DATA`,缩放 0 / 8 / 26 步) | 修复前 | 修复后 | | --- | --- | --- | | A 纵向拖半个网格高 → 持按钮水平微移 | **dy=+15px**(lane0 差异 39245) | **dy=0**(差异 1220,仅已知 ≤0.5px 列相位残差) | | B 仅纵向拖(对照) | dy=0 | dy=0 | | C 仅水平拖(对照) | dy=0 | dy=0 | | D 中间网格纵向+水平 | — | dy=+1(整像素量化残差) | | E 长距水平拖(越过 ±半屏余量)后再微移 | — | dy=0 | | F 斜向拖后再水平 | — | dy=0 | | G 纵向拖 → 松开 → 再按下中键水平微移 | — | dy=0 | - 修复前 A 场景 `H step 1` 起 `vOff` 从 `-15.00` 被清零为 `+0.00`;修复后全程保持 `-15.00`(贴图偏移继续生效)。 - `vOffset == 0` 时新逻辑与旧逻辑**完全等价**(横向补条传的锚点就是当前 `yMax`),因此既有的 「仅水平平移」「仅纵向平移」行为与像素结果不变(已由对照场景 B/C 覆盖)。 - `make clean && make all` 零警告。 ### 9.17 交互流畅性三项修复:光标不再跟随视界 / 缩放首帧不再卡顿 / 超大信号拖预览不拖慢前台(本轮) #### 一、光标不再跟随视界(用户要求) 用户报告:"启用光标时,移动视界导致光标超出视界范围,光标会被强制移动到视界边界; 我需要光标留在原位置而不是跟随视界移动。" - 移除 `ClampCursorsAfterPan()`(含声明)及其在**平移路径**的三处调用:中键/左键拖拽平移、 `PanTimeByFraction`(方向键)、`OnMouseWheel(shift=true)`。 - `ClampCursorToView()` 保留,**只在真正拖拽光标时**使用(其内注释与头文件说明同步更新)。 - 现行为:平移/缩放视界时光标的**时间位置完全不变**,允许滑出可视区(读数贴在网格边缘显示)。 - 验证(`.preflight/perf/zoomcheck` mode 14):平移 20 帧(视界移到 `[257.6, 386.4]s`,光标时间 `38.643036` 已远在视口外)后 `CursorTime` **完全不变**(`unchanged=1`, `offViewport=1`); Shift+滚轮平移同样不变。 #### 二、缩放首帧不再卡顿(用户猜测"必要像素量级飙升导致首帧卡住"——**证实**) - 量化(`.preflight/perf/zoomcheck` mode 0,`VERY_BIG_DATA` 5 网格逐步放大,逐帧计时): 每次缩放变化后 **f1=70~80ms(宽跨度)**、f2=26~40ms,f3 起 0.5ms。 - 根因:跨度变化时 `Paint` 里的 `if (!LayersStructurallyReady()) RebuildWideBuffer();` 会 **同步重建全部 5 个网格图层**(70~80ms);随后一帧又做一次整幅"结算"(40ms)→ 单次缩放 合计约 110ms 的卡顿。 - 修法(用户方案:先用旧图拉伸占位、不卡前台): 1. **旧跨度像素拉伸占位**:`BlitWideBuffer` 遇到 `layer.span != 当前 span` **不再跳过** (跳过会让网格在重画完成前空白),而是把旧图层覆盖的时间段 `[layer.wideMin, +layer.span]` 重新映射到当前视界,用一次 `StretchBlt`(`HALFTONE`)拉出**低分辨率但立即可用**的画面。 2. `Paint` **不再同步整批重建**:只有"图层数量与网格数量不符"(首次绘制 / 增删网格, 新增 `LayersAllocated()`)才 `RebuildWideBuffer()`;跨度 / 内容过期一律入队,由 `RenderPendingLaneRebuilds` 逐帧补齐(原逻辑遇到跨度不符会跳过该网格,现改为直接按**新跨度**重建)。 3. **只在视界稳定后**(`viewStopped`)才花预算做精细重绘:视界仍在变化的每一帧只贴拉伸占位图, 否则缩放序列的每一帧都会夹带一个网格的整块重建(宽跨度约 20ms)→ 持续掉帧。 - 实测:缩放后**首帧 70~80ms → ~10ms**;精细重绘分散到后续帧(宽跨度 ~20ms/帧,深跨度 ~13ms/帧)。 收敛后仍由"结算"整幅帧保证像素精确。 - ⚠️ **保留"结算"帧**(不可省):实测宽基底路径与从零整幅重绘**并非像素同源** (图层 2448px 按 1px 列聚合 vs 整幅 1224px 列,列相位差对稠密脉冲信号放大成 17 万+ 像素差异), 因此必须有这一次全质量整幅重绘来收敛。 - 正确性 A/B(mode 13):缩放并收敛后的画面 vs 从零整幅渲染,改动前后**同为 3774 像素差异** (预存在的列相位/装饰残差),**非本次引入**。 #### 三、超大信号拖预览滑块不再拖慢前台(部分改善,根因未完全定位) - 量化(mode 7/10/12,深放大,单网格预览,模拟面板滑块拖拽 `m_dragMode=PEAK_LEVEL`): - 无预览基线(mode 5):0.38ms/帧。 - 预览 + **参数不变**(mode 12):3.4ms/帧。 - 预览 + **参数每帧变化**(mode 10):11.8ms/帧;弱峰 10 迭代(mode 7):15ms/帧。 - **直接调用 `PaintLaneDerivedOverlays`(mode 9):0.375ms** —— 说明开销**不在曲线绘制**。 - 结论:开销来自"参数每帧变化"触发的**整条信号**(2576 万采样)预览曲线重算调度 (预览曲线按整条信号计算,以保证与「构建为新信号」逐位一致)。 - 缓解:新增 `kHugeSignalPreviewSamples = 4000000` —— 信号超过该规模且**正在拖拽**(`m_dragMode != NONE`)时, 不再每帧排队"整条信号"的重算,改为沿用旧曲线、松手后再算一次最新的(整条信号重算本来也追不上 滑块速度,"冻结"与"每帧重排"观感相同,后者却持续占用后台线程与内存带宽)。 实测 15.0 → 13.7ms(改善有限)。 - ⚠️ **未完全解决**:参数变化带来的 ~8ms/帧开销**尚未完全归因**(已排除绘制;"暂停后台线程"的 对照无效,因为 `EnsureCurveWorker()` 会在暂停后被重新拉起)。用户提议的"网格内按信号并行绘制 + 后台缓冲合成"仍是候选方案,但需先把渲染器改造成**无共享可变状态**(与 §11.2 第 5 条硬约束冲突), 留待后续评估。 #### 四、验证 - `.preflight/perf/pancheck`(平移 / 纵向,7 个场景):全部 `dy=0`(原"中间网格 +1px"也归零)。 - `.preflight/perf/zoomcheck`:mode 0(逐帧首帧耗时)、mode 13(缩放收敛,A/B 无回归)、 mode 14(光标不跟随视界)。 - `make clean && make all` 零警告。 ### 9.18 深放大中键拖拽卡顿根因(逐点描边 AA 病态)+ 横向补条裁剪 + 并行网格图层(本轮) 用户报告:`VERY_BIG_DATA.CSV` 只显示**编码器A相**、选区放大到 `[10.2719, 10.2831] s` (≈1.12ms 一大格)后,中键拖拽视界**非常非常卡顿**。用户同时要求:**做 B 与 C** (B=拆分宽基底路径耗时定位瓶颈;C=解除"不允许多线程"限制并实现并行网格渲染)。 #### 一、B:宽基底路径耗时拆解(`.preflight/perf/dragperf` mode 1) 在该视界逐项计时:`RollWideBuffer`(补条重绘)**13.08ms** / `BlitWideBuffer` 0.50 / `PaintTimeAxis` 0.78 / `PaintLaneDividers` 0.04 / `PaintLaneDerivedOverlays` 0.00 / `CaptureDecorCache` 0.03 —— **瓶颈全在 `RollWideBuffer` 的"补条重绘"**。 #### 二、补条重绘为什么慢 + 裁剪修复 - 补条只覆盖图层位图的**一条窄竖带**(`m_area` 的 x 区间),但折线原本按 `m_plotRect`(整条宽窗口 =2× 视界)构建整段顶点并交给 GDI+ 描边 —— 即使只画窄带,`DrawLines` 仍逐段提交。 - 修法:新增 `m_stripRenderActive/m_stripX0/m_stripX1`,**横向补条时把折线采样区间裁剪到条带对应 x 范围**(两侧各留 `max(4, lineWidth+3)` 像素余量,避免接缝与抗锯齿溢出),并**旁路折线缓存** (补条只构建片段,写进缓存会污染整段复用)。 - ⚠️ **裁剪只对"逐点描边"路径生效,且必须在"包络/描边"分支判定之后**:若先裁剪,`visibleCount` 会变成条带内采样数,可能改变路径判定 → 条带与整块渲染像素不一致(出现接缝)。 - 实测 `RollWideBuffer` **13.08ms → 1.73ms**;中键拖拽整帧 **13~24ms → ~3ms**。 - 正确性(`.preflight/perf/dragperf` mode 3):人为破坏一条竖带后用补条重绘, 与整块渲染逐像素比较 **0 差异**(条带左/中/右/宽条 4 种位置都是 0)。 #### 三、真正的病态根因:GDI+ 抗锯齿描边"满幅锯齿"(本轮最关键) 修掉补条后,**一旦拖拽超出预渲染余量**(`RollWideBuffer` 走整块重画)或缩放落到该视界, 仍会卡:实测**整宽单网格渲染 655ms**(补条仅 1.7ms)。逐层定位: - `BuildPolyline` 只 0.03ms(4481 点、`breaks=1`); - 直接把同样的 4481 点交给 `Graphics::DrawLines`:**AA round ≈694ms / 无 AA ≈29ms(23 倍)**; - 把同样的几何做成"像素列包络带"再 `FillPath`:同样慢。 即:该视界下编码器方波形成**每像素列一次整幅跳变的锯齿**,采样 1~2 个/像素, GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸(关抗锯齿无效是因为原实现只在 `visibleCount > budget(=2/px)` 或"梳状"时关 AA,这一区间两个条件都不满足)。 修法(**几何完全不变,只改"何时走包络 / 何时关 AA"**): 1. **包络带阈值从"每像素 2 个采样(`budget`)"降到"每像素 1 个采样"**:`useEnvelopeBand = visibleCount > m_plotRect.Width`。1~2 采样/像素区间逐列 min/max 与折线几乎等价(且保留 AA)。 2. **扩展"梳状(关 AA)"判据**(`EmitEnvelopeBand` 新增形参 `laneHeight`):除了原有的"相邻列包络不相交", 再加入「多数列在列内就跨过 >0.5×网格高」或「相邻列上下包络整幅跳变(> 0.25×网格高)」 → 判为梳状,**本段关闭抗锯齿**(几何逐列 min/max 一字未改)。 3. 实测:整宽单网格渲染 **655ms → 47.7ms**,横向补条 **6.9 → 2.4ms**; 中键拖拽 200 帧:**avg 4.5ms / max 5.8ms / 0 帧 >8ms**(修复前 13~24ms 且含 700ms 尖峰)。 4. 目视核对(`.preflight/perf/dragperf` mode 9 导出 PNG):编码器波形为正确的密集方波包络带。 #### 四、C:并行网格图层渲染(**用户明确解除"不允许多线程"约束**) - 文档中"图层渲染一律在 UI 线程内同步完成 / 绝不要搬到后台线程"的约束**已按用户要求解除** (见 §11.2 第 5、14 条的新表述),但**根因(共享可变渲染状态)依然成立** —— 因此并行的 唯一安全姿势是**每条线程持有隔离的渲染上下文**。 - 实现(`PlotView::RebuildLaneLayersParallel`):为每条工作线程准备一个**隔离的 `PlotView`** (`m_tab` 指向它**自有的** `m_workerTab` 副本,`m_area`/`m_plotRect`/`m_lanes` 都是它自己的), 各自把不同 `laneIndex` 的图层渲染到**不同的 `m_layerFront` 元素**;主线程 join 后按**真实网格下标** 重算 `LaneLayerKey`(工作线程内部按 `laneIndex=0` 渲染)。 - ⚠️ **踩坑**:`BuildLaneVisuals()` 在 `m_tab == nullptr` 时**会清空 `m_lanes`** → 不能靠"把 `m_tab` 置空 来隔离",必须给它一个**非空的、自有的** `TabConfig`(`m_workerTab`)。 - 正确性(`.preflight/perf/dragperf` mode 5):并行 vs 顺序逐网格比较 **0 差异**(5 网格全 0)。 - **收益(诚实结论)**:`RebuildWideBuffer` 并行 31.1ms vs 顺序 32.0ms → **仅 1.03x**。 同一进程自检证明 **4 线程 CPU 真并行(3.98x,hw=20)**,因此**瓶颈是 GDI+ 光栅化本身的串行化**, 不是调度。→ **并行对"光栅化"没有收益**;因此**保持"视界停后每帧补 1 个网格"**的策略 (把一帧从 ~10ms 抬到 ~31ms 反而更容易掉帧),并行只用于"本来就要一次画完"的 `RebuildWideBuffer()`。 #### 五、验证 - `.preflight/perf/dragperf`:mode 0(拖拽帧时)、mode 1(B 拆解)、mode 3(补条逐像素)、 mode 5(并行逐像素)、mode 6(重建耗时)、mode 8(线程真并行自检)、mode 9(PNG 目视)。 - `.preflight/perf/pancheck` 7 场景全 `dy=0`;`.preflight/perf/zoomcheck` mode 0/13/14 无回归 (f1 8~9ms,缩放收敛 3649 差异=预存在残差)。 - `make clean && make all` 零警告。 --- ### 9.19 远端「调整」功能带来的性能回归 + 首帧卡顿根治 + 图例交互/托底重做(本轮) > 接手背景:远端(`c334390`…`940b8e6`)新增了样式区「调整」(系数/绝对值/镜像/偏移)与 > 标题栏内存占用等。本轮先**逐项量化**再修,全部离屏验证(不占用鼠标/键盘/屏幕)。 #### 一、性能回归:`AutoRangeLaneToView` 把 `Style(ref)` 放进了逐采样循环(**8 倍**) - **症状**:加载 `VERY_BIG_DATA.CSV` 并显示全部信号后,**点一下工具栏「Y 轴自适应」开关**就卡顿 很久;从局部放大状态 `FitAll` 回全局同样卡。 - **量化**(新增 `.preflight/perf/zoomcheck` mode 21/23): - `SetAllLanesAutoFitToSelection(true,true)`(全视图、5 个网格):**修复前 1200ms → 修复后 0.02ms**; 旧提交(`20b9084`)为 **147ms** —— 即远端把这一步劣化了约 8 倍。 - `AutoRangeLaneToView(0)`(单网格、全视图):**修复前 ≈240ms → 修复后 0.012ms**,且结果与 「逐采样扫描」的参考值**逐位一致**(`match=1`)。 - **根因**:远端为支持「调整」变换,把 `m_dataSet->Style(ref)` **写在了逐采样循环体里**(还写了两次)—— `Style()` 每次都要解析文件/信号下标;2580 万 × 5 次 × 2 = 亿级解析调用。 - **修法(显示效果零变化)**: 1. **把样式指针提到循环外**(`resolvedStyle`),并预计算 `mirrored` / `transform` 两个布尔。 2. **整段自适应走 O(1)**:`timeMonotonic && begin==0 && end==count && !mirrored && !transform && finiteCount>0` 时直接取信号**已缓存的 `minValue/maxValue`**(与 `ComputeStats` 同源, 因此结果与逐点扫描完全一致)。 3. 无变换时走**紧凑循环**(无逐点分支,便于向量化);有变换/水平镜像时逐点套用与绘制相同的变换。 - ⚠️ 有「调整」变换时仍必须逐点扫描(158ms/单网格,全视图)——这是变换语义的固有代价。 #### 二、首帧卡顿根治:**空闲预热「每网格信号图层」** - **复现与量化**(`zoomcheck` mode 22,`VERY_BIG_DATA` 5 网格,同一负载下 A/B): 加载 → 静止 → 第一次「选区放大」的**首帧**:旧提交 **64.3ms**、本轮修复前 **54ms**。 - **根因**:加载后画面走的是「静态场景缓存贴图」,**宽基底图层从未被分配**;用户第一次平移/缩放时 `Paint` 里 `if (!LayersAllocated()) RebuildWideBuffer();` 会在**那一帧同步重建全部 5 个网格图层** (实测整帧 = 5 个图层同步重建 + 占位拉伸)。逐项拆解(mode 25/26): 占位 `StretchBlt`(HALFTONE)×5 ≈16ms、装饰层命中缓存仅 0.4ms、**其余全是整批图层重建**。 - **修法**:新增 `PlotView::WarmLaneLayersStep()`,在**命中静态场景缓存**的那条快路径里调用 (⇒ 本帧视图与缓存同源、**视界没有变化**、且**不在拖拽中**): - 图层数组与网格数量不符时先对齐(多余图层 `ReleaseLaneLayer` 后 `resize`); - 用既有预算机制 `RollWideBuffer()` + `RenderPendingLaneRebuilds(1)` **每帧至多补 1 个网格**, 队列非空时 `PostMessage(kMessagePlotDerivedReady)` 让宿主再送一帧,直到补齐。 - ⇒ 整批重建的代价被**摊到若干静止帧**上,**任何交互帧都不再承担它**。 - **效果**(同一负载,mode 22):首帧 **64.3ms → 13.0ms**(≈5 倍);`zoomcheck` mode 0 逐步缩放 f1 也同步下降。图层数一旦就绪,后续交互全程走"占位拉伸 + 每帧补 1 个网格"。 - ⚠️ 预热只在**视界不变**时推进,因此不会在用户拖动时做无用功;视界一变,宽基底路径自己按预算补。 #### 三、派生信号:把「调整」**烘进数据**,不再继承指纹 - **用户要求原文**:"重建出来的新信号原本不经过调整就已经是预览状态的样子,而不是继承指纹"。 - **旧行为**(`fc77b02`):把源信号的 `gain/absolute/mirrorVideo/mirrorHorizontal/offsetY/offsetTime` 复制给新信号的 `SignalStyle` —— 新信号只是"套了一层同样的调整",数据本身没变, 「调整」面板里还莫名带着一整套参数。 - **新行为**(`Application::BuildDerivedSignal`): 1. 快照源信号样式; 2. 滤波结果算完后,**逐点套用 `ApplyDrawTransform`**(系数 → 绝对值 → 垂直镜像 → Y 偏移)写回采样值; 3. **水平镜像 = 数据反序**:把派生采样数组整体反转(`ApplyDrawTransform` 是逐点函数, "先变换再反转"与"先反转再变换"等价); 4. 新信号样式一律**保持默认**(`gain=1 / absolute=false / 两个镜像=false / offsetY=0`)。 - ⚠️⚠️ **【已作废,见 §9.21 第三节】原"唯一例外:时间轴偏移 `offsetTime` 仍由样式携带"** —— 用户明确要求"新信号**默认不启用任何「调整」**就必须与预览逐点重合",因此 `offsetTime` 现在也 **按 `time[i] - offsetTime` 重采样烘进采样值**(新信号 `offsetTime` 同样保持 0); 只有"无法烘值"的退化情形(时间轴非单调 / 时间列缺失)才退回把 `offsetTime` 带在样式上。 仅作历史保留:~~它改变的是"绘制在哪个时刻",而派生信号与源信号共用同一个文件时间轴, 无法把时间平移烘进采样数据;若不继承,新信号会相对预览整体横向错位。~~ - 验证(`1b3ab4a` 轮 `verify` harness):`derived.values[i] == ApplyDrawTransform(tf, float(source[count-1-i]))` 逐点一致;新样式各项均为默认。 #### 四、网格 GX 拖拽让位阈值 + 徽标悬停 - **让位判据改为「鼠标指针」**(`LaneDragTargetAt`):`index = floor((mouseY - firstTop)/laneHeight + 0.5)`。 - 旧判据用"**被拖网格的中心**",等价于要求把整个网格拖过去一格才吸附(用户报告"要拖到另一个网格 的位置附近才有吸附提示")。新判据下**只有两个网格时,鼠标越过 G1 的中心水平线,G1 立刻让位并 出现吸附提示**;任意网格数下都成立(进入相邻网格的上半即让位)。 - **GX 徽标悬停提示**:徽标本体画在静态场景 / 装饰缓存里(被冻结),因此悬停高亮必须是**叠加层** ——新增状态 `m_hoverBadge`(`OnMouseMove` 里更新、`OnMouseLeave` 里清零,因为鼠标在同一网格的 徽标范围内移动时 `m_hoverLane` 不变),`PaintLegendHighlight` 在贴图之后**重画徽标** (不透明底色铺满外接矩形 + `accentSoft` 圆角底 + `accent` 描边 + `textStrong` 文字), 与左侧信号列表里同一个 GX 徽标的悬停效果完全一致;`QueryCursor` 在徽标上返回 `IDC_HAND`。 #### 五、图例:命中矩形在装饰缓存路径下**必须重建**(悬停/点选失效的真因) - **症状**:一旦用中键平移过视界,网格图例的**悬停高亮 / 点击选中 / 双击改名全部失效**。 - **根因**:`m_legendHits` 只在**整幅重绘**路径开头被 `clear()`,随后靠 `PaintLaneLegend` 重新填充; 而装饰缓存命中时 `PaintLaneLegend` **提前 return**(跳过绘制)→ 命中矩形列表**被清空且永不重建**。 中键平移后必然出现一次"装饰缓存有效的整幅重绘"(结算帧),于是图例从此点不动。 - **修法(结构性)**:把 `PaintLaneLegend` 拆成「**几何 → 命中/装饰矩形 → 绘制**」三段: - 先用**唯一来源**算出"放得下的条目"及其托底矩形(命中矩形 == 托底矩形,看到的与点得到的严格一致); - **无论是否命中装饰缓存都重建 `m_legendHits` 与 `m_decorRects`**,之后才 `if (m_useDecorCache) return;`; - 绘制段只负责画像素。 #### 六、图例托底 + 消除「旧波形像素残留」 - **症状**:中键把密集波形区域推过左上角 GX 编号/图例区域后再移开,**GX/图例周边残留旧波形像素**。 - **根因**:远端 `c334390` 去掉了图例的整块不透明托底后,装饰缓存捕获的那**一条大矩形** 里包含了**它下面的波形像素**(波形会透出),而装饰指纹 `SceneDecorHash()` 不含视界 ⇒ 平移贴回时把旧视界的波形一起贴了回来。 - **修法**: 1. **每个图例条目各自一块圆角托底**(`kLegendPlateRadius = 4`),风格与 GX 徽标一致 (`cardBg` 底 + `border` 描边;选中态用 `selection` 底 + `accent` 描边); 2. **装饰矩形改为逐块**(徽标一块 + 每个条目一块 + 每个 Y 轴胶囊一块), **不再覆盖整条图例带** —— 中间的波形像素因此完全不进缓存; 3. 每块托底先用**不透明底色铺满其"外接整数像素再外扩 1px"的矩形**再画圆角: 否则圆角之外那几个像素仍是波形,会被烘进缓存(`CaptureDecorCache` 的边界由 `floor/ceil` 得出, 必须完全落在不透明填充内)。Y 轴胶囊同理。 4. 悬停高亮(随鼠标变化)画在**贴图之后**:不透明 `accentSoft` 圆角矩形 + `accent` 描边, 并**在原托底上重绘色块与文字**(`LegendHit` 现在记录色块矩形/文本矩形/颜色/已省略文本)—— 不透明托底若只画底色会把图例内容整块盖住(只剩一个空色块)。 悬停时**同时**画对应信号的曲线光晕(`PaintSignalHaloFor`)。 - **实测**(`verify` harness):把"宽基底快路径的实时帧"与"同一视界从零重绘的装饰"逐像素比较, **全部装饰矩形内差异 = 0**(`decor ghost check: total=0 worst=0 rects=23`)—— 直接证明缓存像素与时间视界无关、不会残留旧波形。 #### 七、验证工具(本轮新增,均在 `.preflight/` 下,不入库) - `.preflight/perf/verify_main.cpp`(`build.ps1 -Main verify_main.cpp -Out verify.exe`): **离屏 Application harness**(`Initialize(GetModuleHandleW(nullptr), SW_HIDE)` + 直接注入鼠标消息 + 从 `m_backDc` 截图),**40 项检查全部通过**,覆盖:自适应正确性(恒等/变换/镜像、与逐点扫描逐位一致)、 派生信号烘值与样式默认、拖拽让位阈值(鼠标越过中线即让位)、平移后图例命中与可点选、 装饰矩形不覆盖整条图例带、图例悬停有可见高亮、GX 徽标悬停有可见高亮且离开后清除、 **装饰矩形内 0 像素残留**。还会导出 `verify_legend.png` / `verify_legend_hover.png` 供目视核对。 - `.preflight/perf/zoomcheck_main.cpp` 新增 mode:20(FitAll from deep zoom)、21(全局 Y 轴自适应开关)、 22(选区放大首帧 + 图层的空闲预热进度)、23(自适应**正确性**与耗时)、24(带着色自适应 FitAll)、 25(首帧拆解)、26(占位拉伸 HALFTONE vs COLORONCOLOR 与装饰重绘耗时)。 - 旧版本 A/B 基线用 `git worktree add --detach ..\jdi_old 20b9084` 建立。 --- ### 9.20 装饰层改为「带 alpha 的独立图层」+ 图例圆角托底 + 预览曲线内存释放(本轮) #### 一、GX 编号 / 图例「圆角外多出一圈直角白边」的根因与修复 - **症状**:把密集波形(如 `VERY_BIG_DATA` 的编码器信号)平移到 GX 编号与图例下方时, 明明是圆角矩形的 GX 标签 / 图例托底,**外边却多出一个直角长方形**(浅色底 = 网格底色),非常突兀。 - **根因**:装饰层原来是「**从后备缓冲区抓取一块不透明矩形**」—— `CaptureDecorCache()` 用 `BitBlt` 把 `m_decorRects`(矩形)里的像素搬进 `m_decorDc`。 要让这块**矩形**像素跨视界可复用(不残留旧波形),又必须在圆角矩形之外**铺一层不透明底色** (`OpaqueBaseRect`,比元素大 1px 的直角矩形)。两者叠加 ⇒ 圆角之外是"网格底色"而不是波形 ⇒ 在密集波形上就是**一圈直角白边**。Y 轴刻度胶囊同理。 - **修法:把装饰层改成带真 alpha 的独立图层**(不再"抓矩形"): 1. `m_decorDc` 改为 **32bpp `CreateDIBSection`**(自上而下),像素可直接读写 alpha; 2. 新增 **`RenderDecorCache()`**:把装饰**单独绘制**进一张全透明的 DIB(`memset` 清零后 `m_renderDecorOnly = true` 逐网格 `PaintLane`)—— 圆角之外保持全透明; ⚠️ 位图覆盖范围 = **客户区原点 → 绘图区右下角**,绘制坐标与正常绘制**完全一致** (GDI 文本不认 GDI+ 变换,绝不能做坐标平移); 3. `BlitDecorCache()` 改用 **`AlphaBlend`**(`AC_SRC_ALPHA`,GDI+ 写入 DIB 段即**预乘** alpha) 逐块合成 ⇒ 圆角之外透出的是**当前**波形; 4. **`AlphaBlend` 用 `GetProcAddress` 解析**(`gdi32!GdiAlphaBlend`,回退 `msimg32!AlphaBlend`), **不新增链接依赖**(两台协作设备工具链不同,避免改 Makefile); 5. ⚠️ **alpha 修补**:GDI 文本(`DrawTextW` / `ExtTextOutW`)会把写入像素的 alpha **清成 0** (GDI 不维护 alpha),若不修补,文字位置会变成"透明洞"(透出波形)。 因此在装饰矩形内把「`A == 0` 且有颜色(`RGB != 0`)」的像素还原为不透明 —— 装饰本就画在不透明底板上,这些像素本来就该不透明; 6. 删除全部 `OpaqueBaseRect`(外扩的直角不透明底色)。Y 轴胶囊与图例托底都只画**圆角形状本身**。 - **顺带修好的一处遗漏**:图例的「`+N`」(空间不足提示)与「(无可见信号)」也是装饰层内容, 必须**登记进 `m_decorRects`**,否则会被画进装饰位图却因不在贴回列表里而永远看不见。 #### 二、图例样式:与 GX 编号徽标同款圆角托底 + 悬停/选中配色统一 - 每个图例条目 = **一个圆角矩形托底**(`kLegendPlateRadius = 4`,与 GX 徽标同风格的 `cardBg` 底 + `border` 描边),内容 = 色块 + 信号名 —— 用户要求"近似于一个 GX 标签, 只是内容换成了图例信息"。 - **选中态与悬停态同配色**(用户明确要求"选中状态与鼠标悬停时的提示效果相同"): `accentSoft` 底 + `accent` 描边 + `textStrong` 文字 —— 与 GX 徽标的悬停效果完全一致。 - 悬停高亮画在**贴图之后**(叠加层):先铺不透明 `accentSoft` 圆角矩形 + `accent` 描边, 再在其上**原样重绘色块与文字**(否则不透明托底会把内容盖住),并保持**对应信号的曲线光晕** (`PaintSignalHaloFor`)。`LegendHit` 新增 `radius` 字段供悬停重绘使用。 - 选中态与左侧信号列表**同源**(`DataSet::Selected()`):面板选中 → 图例高亮;图例点选 → 面板行选中。左右一致由同一条数据保证。 - ⚠️ 沿用上一轮的硬约束:`m_legendHits` **必须**在**任何**绘制路径(含装饰缓存命中)先重建。 #### 三、预览曲线内存:thread_local 工作缓冲封顶 + 关闭功能即释放 - **症状**(用户报告):`VERY_BIG_DATA` 加载并显示全部信号 ≈1.42GB;对某个网格开启弱峰调整 (带预览曲线)后立刻 ≈2.17GB;迭代次数拉到 10 后 ≈4.21GB;**点浮动面板的 ✕ 关掉预览,内存仍然 不下降**;只有"关闭整个文件分组"才释放。 - **根因**:`Utilities/Src/DataModel.cpp` 里三处 `thread_local` **工作缓冲**(弱峰调整的 `currentBuffer`/`nextBuffer`/`extremesBuffer`、`ApplyWeakPeakStage` 的 `shiftBuffer`、 迭代均值滤波的 `bufferBuffer`)。整段信号(2580 万采样)计算时,每条后台线程会常驻 **数百 MB**;后台曲线线程池有 **4 条线程** ⇒ 2GB 以上。而 `thread_local` **只在线程退出时** 才析构 ⇒ 关掉预览不释放,只有 `PauseDerivedAsync()`(关文件分组会调用)join 线程后才归还。 - **修法(两处)**: 1. **保留容量封顶**:新增 `kReuseBufferMaxElements = 4M` + `TrimReuseBuffer` + `ReuseBufferScope`(作用域守卫,**所有退出路径**都会裁剪)。容量超上限的"超大计算" 改为**一次性分配、用完即释放** —— 这类整段计算本身是秒级的一次性操作(结果会被曲线缓存复用), 内存不再随线程数放大;中/小规模仍保留容量以维持"多阶弱峰调整 991ms → 577ms"的收益。 2. **关闭功能即释放**:新增 `AnyDerivedPreviewEnabled()`(本标签页是否还有网格开启 弱峰调整 / 均值滤波 / 边沿计数)与 **`TrimDerivedCurveDataIfIdle()`**;在 `SetLaneWeakPeak/SetLaneMeanFilter/SetLaneEdgeCenterCount(..., false)` 里调用 —— **关闭最后一个预览功能时立即 `PauseDerivedAsync()`**(join 线程 ⇒ thread_local 析构 ⇒ 内存归还)并清空均值/弱峰曲线缓存。 - **实测**(新增 `.preflight/perf/stylecheck` mode 2,`VERY_BIG_DATA`): `baseline 1202MB → 弱峰(10 迭代, 1 条) 1399MB → 5 条曲线 1792MB → **关闭面板后 1203MB**` (修复前该值不下降)。5 条整段曲线的驻留仍受曲线缓存预算(`kCurveSpanCacheMaxBytes` 768MB)约束。 - ⚠️ **诚实说明:计算期间的瞬时峰值仍可达 ≈2.2GB**(同一进程重复测量为 1.4~2.2GB)—— 一次整段弱峰调整需要 `out` + 2 个 float 工作缓冲(各 ≈103MB / 2580 万采样), 多线程/多次重排时会有 2 份同时在算。这是"整段 O(n) 计算"的固有代价 (要彻底消除需把算法分块或限制大任务的并发度),**但它在计算结束后立刻归还**, 不再像修复前那样永久驻留。 #### 四、性能与保真度实测(`stylecheck` / `zoomcheck`) | 指标 | 结果 | | --- | --- | | `RenderDecorCache`(重建装饰位图,仅装饰内容变化时执行) | **2.40ms**(5 网格) | | `BlitDecorCache`(**每帧**贴回,`AlphaBlend`) | **0.19ms**(原 `BitBlt` 方案约 0.3ms) | | `zoomcheck` mode 13(收敛帧 vs 整幅重绘) | **逐像素 0 差异**(此前为 3503 像素) | #### 五、验证(全部离屏,不占用鼠标/键盘/屏幕) - `.preflight/perf/verify_main.cpp`(`build.ps1 -Main verify_main.cpp -Out verify.exe`) **49/49 通过**,本轮新增/强化: * **装饰位图圆角外 alpha ≈ 0、中心 alpha = 255**(直接读 `m_decorBits`)—— 直接证明"没有直角白边"; * **选中图例 = 悬停配色**(在托底矩形内统计 `accentSoft` 像素:未选中 0、选中 1525); * **平移(宽基底)帧的装饰与整幅重绘逐像素一致**(只比较装饰**不透明**像素:`worst=1 / 29554 px`; 圆角外的波形属于已知的 ≤0.5px 整数像素平移残差,不参与比较); * **图例溢出的「+N」提示**:在窄布局 + 合并网格下断言提示矩形已登记且可见(墨迹 570px); * 图例悬停可见高亮(2011px)、GX 徽标悬停可见高亮(1122px)、装饰矩形内 0 像素残留。 - `.preflight/perf/stylecheck_main.cpp`:mode 0(装饰重建/贴回耗时)、mode 1(密集波形下导出 GX+图例角落 PNG 供目视)、mode 2(预览曲线内存增长/释放)、mode 4(装饰位图 alpha 形状检查)。 - `make release` 零警告。 --- ### 9.21 边沿计数改用「绘制值」+ 派生信号烘入时间偏移 + 图例托底统一为 GX 徽标款 + 调整二级面板向上展开(本轮) > 接手背景:基于 `1b3ab4a`。本轮修的四处都是**交互/统计语义**问题,另有**一处推翻了 §9.19 的结论** >("派生信号仅 `offsetTime` 仍需继承" —— **已作废**,见下)。 #### 一、两个「边沿计数」必须按**画出来的曲线**判定(原实现按原始采样值 → 恒为 0) - **定义(用户明确复述,作为权威)**:设定一个横向(Y 轴)光标,信号**绘制出来的曲线** 从**下往上**穿过该幅值 = **上升沿**;从**上往下**穿过 = **下降沿**。 - **症状**:对应用过「调整」的曲线统计恒为 0。例:`VERY_BIG_DATA.CSV` 的「编码器B相」设 系数 50、时间轴偏移 -20s,把横向光标拖到画面中间(方波很规整),**上升沿/下降沿都是 0**。 - **根因**:`EdgeCountsFor` / `EdgeCenterCountsFor` 只比较 **`signal.values` 的原始采样值**, 而横向光标读的是**屏幕上的值**(已乘系数、已加偏移)→ 原始值域与阈值完全不相交。 - **修法**:判定值一律取**绘制值** `ApplyDrawTransform(style, values[SourceIndexFor(style, count, index)])`; * 中心轴版同构:两侧都套用同一套变换(`drawn - drawnCenter`),否则比较的不是屏幕上的两条曲线; * **水平镜像**时曲线数组按**源下标**存储、而区间是**显示下标** → 请求曲线窗口前必须把 `[needFirst, needEnd)` 映射为 `[count-needEnd, count-needFirst)`,否则取到完全无关的一段。 - ⚠️ **缓存必须带样式指纹**:`EdgeCountEntry` / `EdgeCenterCountEntry` 新增 `styleKey` (`EdgeStyleKey()`:`gain`/`offsetY` 按 1e6 定点 + `absolute`/`mirrorVertical`/`mirrorHorizontal` 位), 否则改完「调整」后仍会命中变换前的旧计数。 #### 二、时间区间**之外**的采样不得计入(区间边界点) - **症状(用户报告)**:A 在阈值以下、B 超过阈值、C 比 B 高;双光标下**一个光标正好在 B 点**、 另一个在 C 之后 → 计数区间应是"B 之后",**B 本身不该算作一个上升沿**,但旧实现把它算进去了。 - **根因**:`EdgeIndexRange`(时间轴单调时的下标区间)在**左端多取了一个采样点** (`if (begin > 0) --begin;`,注释写着"使跨越区间边界的边沿也能被计入")→ 采样对 `(A, B)` 的两个端点里有一个在选区之外,却被计入。 - **修法**:**删掉该外扩**,并把语义收紧为「**只有两个端点都落在 `[begin, end)` 内的采样对** 才计入」(`begin = lower_bound(rangeMin)`、`end = upper_bound(rangeMax)`,调用方从 `begin + 1` 枚举到 `end - 1`;增量路径的差集公式不变)。非单调时间轴的逐点 NaN 过滤本来就是这个语义。 - 结果:把区间起点从 `times[crossing-1]` 移到 `times[crossing]` 恰好**少一个上升沿**(已断言)。 #### 三、派生信号把 `offsetTime` **也烘进采样值**(**推翻 §9.19 的"仍需继承"**) - **用户要求原文**:"派生信号与显示的预览信号需要相同,且烘焙出来的新信号**默认不启用任何「调整」** 时就应该与预览时相同"。→ §9.19 写的"时间轴偏移是唯一的例外,仍由新信号的样式携带"**已作废**。 - **修法**(`Application::BuildDerivedSignal`,在原有「值变换 + 水平镜像反序」之后追加一步): * 新信号第 i 个采样 = **预览曲线在 `time[i]` 时刻显示的值** = 已变换的曲线在 `time[i] - offsetTime` 处的取值(**取最近的采样点**); * 单调时间轴的 `target = time[i] - offsetTime` 随 i 单调不减 → 用**只前进的游标**,整段 O(n); * 平移后越界的采样置 **NaN**(与预览"该处没有曲线"一致); * 新信号样式**全部保持默认**(`gain=1 / absolute=false / 两个镜像=false / offsetY=0 / offsetTime=0`)。 - ⚠️ **退化路径**:时间轴非单调或时间列缺失时无法按下标推进取样 → 退回"把 `offsetTime` 带在 新信号样式上"(两条路径的**显示效果相同**,只是一条把偏移留在样式里)。 - 代价:整段重采样需要一份等长临时缓冲(千万级采样 ≈ 数十 MB),用完即释放;属一次性构建操作。 #### 四、图例托底统一为 **GX 编号徽标同款**(并新增更小的 `uiTiny` 字体) - **用户要求**:"修改图例的托底为 GX 编号同款,大概可以直接复用 GX 编号的样式,只不过「GX」部分 变成图例色块和文本;注意现在的图例文本高度太大了可能放不下 GX 标签同款的托底,这个需要调整 字符大小,并且左右边距和上下边距与 GX 标签的样式相同"。 - **实现**(`PaintLaneLegend`): * 托底**高度 = `kLaneBadgeHeight`**、上下边界与 `LaneBadgeRect()` **完全一致**(同一 legendBand 内垂直居中); * 圆角 = **高度的一半**(胶囊形,与徽标的 `badgeHeight * 0.5f` 一致);配色/描边与徽标相同 (`cardBg`/`border`,选中态 `accentSoft`/`accent`); * **水平内边距 = 胶囊半径**(小于半径时内容会溢出圆角之外); * 文本改用**新增字体 `UiFonts::uiTiny`(10 逻辑 px)**:原 `uiSmall`(12px)几乎顶满 17px 高的胶囊; * `LegendItem` 拆出 `plateWidth`(= 内容宽 + 2×内边距),条目步进 = `plateWidth + kLegendItemGap`; `kLegendPlateRadius` **已删除**(半径一律由徽标高度推出)。 - ⚠️ `LegendHit.rect` **同时是命中矩形与悬停重绘的托底** —— 看到的必须严格等于点得到的; `LegendHit` 里保存的 `swatch/text/label/颜色/radius` 也必须与绘制用**同一套**(悬停时在不透明托底上原样重绘)。 #### 五、「系数/偏移」「镜像」二级面板改为**向上展开** - `PopupMenu::Open(scope, bounds, x, y, items, bool expandUp = false)` 新增末位参数: `expandUp = true` 时 `(x, y)` 是菜单的**左下角**、菜单**向上展开**(默认仍是原来的"向右下展开、 `(x, y)` = 左上角");`BuildLayout` 增加同参数并记录 `m_buildExpandUp`(`RebuildLayout` 复用它, 否则数值回写导致的重新排布会让面板跳回向下展开)。 - `SignalPanel::OpenGainMenu` / `OpenMirrorMenu`:锚点 = `(button.X, button.Y - kAdjustMenuGap)` (新增常量 `kAdjustMenuGap = 4` 逻辑像素)→ **面板左下角对准按钮左上角、再略微上去一点**; 超出窗口时仍会向左翻转并做边界收敛。 #### 六、验证(全部离屏,不占用鼠标/键盘/屏幕) - `.preflight/uitest`(窗口 `SW_HIDE` + 直接注入消息 + 从 `m_backDc` 截图)新增 `[AR]`: * 边沿计数:`plain` / `gain=50`(阈值在**绘制刻度**上)/ `offsetY`(整条曲线被抬到光标之上 → 0 个边沿)/ `mirrorVertical` / `mirrorHorizontal` **逐个与独立参考实现(按定义逐点重算)逐位一致**; `offsetY` 用例同时给出 A/B:**按原始采样值算会得到 7/6 个边沿**(正是用户报的缺陷); * 区间边界:把区间起点从 `crossing-1` 移到 `crossing` **恰好少一个上升沿**,且与参考实现一致; * 中心轴:`absolute + offsetY` 的样式下与参考实现**逐位一致**(32234 个边沿量级); * 派生信号:无偏移时**全部 128 万采样**逐点等于 `transform(滤波)`;带 +10 个平均采样周期的偏移时, 抽样点等于 `transform(滤波)` 在 `time[i] - offsetTime`(最近采样)处的取值;**样式全默认**; * 图例托底:`height == 徽标高度`、`Y == 徽标 Y`、`radius == height/2`、色块/文本都在托底之内; * 调整面板:菜单左边界 == 按钮左边界、`菜单底 == 按钮顶 - 4*scale`、且**不覆盖按钮**(两个按钮各测一次); * GX 拖拽:`±0.6H` 分别让位 1 格(目标 0 / 3),`±0.4H` 与**恰好 ±0.5H 都不让位**(严格"超过")。 - ⚠️ **同时把 harness 里两处"旧规则"断言改成新规则**(否则它们必然 FAIL,且会误导后来者): `[AP]` 原断言"派生信号继承调整变换(与预览一致)"→ 改为「样式默认 + 采样值按 `time[i]-offsetTime` 烘焙」; `[AN]` 原断言"图例带无托底(网格线可透出)"→ 改为「装饰矩形**逐块**、无一块跨越整条带、 未被托底覆盖的列仍能看到底层内容(非网格底色像素 > 0)」。 - 合计 **722/722 通过**;`make release` 零警告。 - ⚠️ **本机提交时 `Target.exe` 常被用户占用**(链接报 `cannot open output file ... Permission denied`): 编译不受影响,**不要 `make clean`**、也不要杀用户进程;用临时 `-o Build/Release/Target_check.exe` 手工链接一次即可验证链接正确性。 --- ## 10. 变更日志(本文件维护记录) | 日期 | 设备/模型 | 摘要 | | --- | --- | --- | | 2026-10-11 | DeepSeek-V4.1-Flash | 新建本文件;记录项目要点与规范;修复「实验性功能」二级菜单瞬间关闭问题。 | | 2026-10-11 | DeepSeek-V4.1-Flash | 修复右键网格菜单后悬停光晕/提示气泡残留;修复「启用带预览曲线的实验性功能后平移/拖光标 300ms+ 卡顿」(`paintDerivedCapsule` 巨窗每帧重算,收窄请求窗口,显示效果零变化)。 | | 2026-10-11 | DeepSeek-V4.1-Flash | 深度修复「实验性功能预览曲线导致的交互卡顿」:大窗口改入缓存(静止重绘 2247ms→0.25ms)、交互期间延迟重算(弱波增强拖滑块 2303ms→18ms)、计算缓冲复用;显示逐像素不变。 | | 2026-10-11 | DeepSeek-V4.1-Flash | 架构升级:**派生曲线计算与 UI 绘制分离**(后台线程 + 双缓冲)。绘制线程永不等计算,弱波增强拖滑块/平移从 2303/2247ms 降到 ~15ms;结构变化前 `PauseDerivedAsync` 保证线程安全;小窗口同步路径与旧实现逐像素 0 差异。 | | 2026-10-11 | DeepSeek-V4.1-Flash | 完善异步:后台改**多线程池**(预览曲线并行计算、更快出现);`PaintLane` 分两遍把**预览曲线画在全部原信号之上**(不再被覆盖);**快速帧只用于改主体的拖拽**(拖光标/框选改为命中场景缓存,约 1–4ms)。小跨度逐像素 0 差异。 | | 2026-10-11 | DeepSeek-V4.1-Flash | 预览曲线改为**按整条信号计算**,与「构建为新信号」逐位一致;新增**平移装饰层像素缓存**(图例 + Y 轴刻度在拖拽时直接贴回,2ms/大格拖拽 13ms→约 5ms);后台任务按 (信号,种类) 去重以防拖滑块淹没线程池。 | | 2026-10-11 | DeepSeek-V4.1-Flash | 探针定位"样式/参数滑块在深放大下极卡":根因是**样式补丁用了抗锯齿**(≈72ms/网格)且**后台曲线发布时整幅作废**导致每帧整幅重绘。修复:样式补丁强制快速帧、拖拽中不整幅作废、交互期省略面板投影 → 透明度/粗细/颜色 77ms→16ms、弱波增强滑块 96–176ms→26ms,静止/结算帧逐像素不变。 | | 2026-10-11 | DeepSeek-V4.1-Flash | 落地用户提出的**复用像素**方案:新增**平移宽缓冲区**(以视界为中心左右各多渲染半个视界宽),平移时前台只做一次 **BitBlt 截取子矩形**(O(1)),移出边界才重建 → 深放大下平移 **34ms→1.3ms**;静止/结算帧逐像素 0 差异。 | | 2026-10-11 | DeepSeek-V4.1-Flash | 把宽缓冲推广为**基底复用**:抽出 `PaintLaneDerivedOverlays`、宽缓冲只存基底(不含派生曲线),基底键排除派生参数 → 拖弱波增强滑块 **55ms→5.2ms**;平移 1.3ms、样式 8ms 不变;静止帧逐像素 0 差异。残留:宽缓冲重建在前台(约每屏一次)、非拖拽视界变化仍整幅重绘。 | | 2026-10-11 | DeepSeek-V4.1-Flash | 宽缓冲改为**滚动式**(自贴图平移 + 只补画新暴露细条),消除"移出边界整幅重建"的周期性尖峰;缓冲只覆盖绘图区、时间轴前台绘制、亚像素误差用锚点限制在 ≤0.5px → 持续拖动平移约 6ms/帧、无尖峰(长距最大 10.5ms);静止帧逐像素 0 差异。残留:每次开始拖拽的首帧需新建缓冲(一次启动尖峰)。 | | 2026-10-11 | DeepSeek-V4.1-Flash | 消除"开始拖拽"的首帧尖峰:平移首帧用**静态场景缓存初始化宽基底**(`InitWideBufferFromScene`,贴图 + 细条,不再从零重画 2×视界);启用带预览的实验性功能时**预建基底**。实测:平移拖动最大 8.5ms、弱波增强滑块最大 10.8ms(原分别 35/37.6ms),用户全部鼠标交互在该放大区间**全程 ≥60fps、无尖峰**;静止帧逐像素 0 差异。残留:非拖拽视界变化(键盘/工具栏)仍整幅重绘。 | | 2026-10-11 | DeepSeek-V4.1-Flash | 消除 9.13 残留:**非拖拽视界变化(键盘/工具栏)也走宽基底快路径**(124.6ms→**8.6ms**),并用 `wideKey` 相等判定"视界已停止变化"触发一次**结算**整幅全质量帧以恢复像素精确;分支顺序固定为"静态贴图 → 宽基底 → 样式补丁 → 整幅",宽基底强制全质量抗锯齿且竖向刻度用前台跨度。新增像素对比模式验证:底座管线忠实(meanAbs 0.68/255),平移期仅 ≤0.5px 亚像素差;7 张静态/结算帧与 HEAD **字节一致**;平移中途帧不再有图例/刻度重影。 | | 2026-10-12 | DeepSeek-V4.1-Flash | 落地用户提出的「各网格独立双缓冲区 + 后台线程绘制」架构:把全局宽缓冲区重构为**每网格信号图层**(宽×3,只含基底不含装饰)、`m_layerFront/m_layerBack` **双缓冲** + `m_layerWorker` **后台线程**(前台只做 BitBlt 坐标偏移,不再绘制波形);前台"图层就绪即用现成像素、内容稍旧也照用,交后台补画后交换";加不收敛保护、前台优先让路、固定加锁顺序(render→layer)、渲染状态完整还原(含 `m_laneContentHeight`/`LaneScroll`)。新增 `BackgroundLayerRenderCount()` 公开计数用于验证后台绘制确实发生(一轮诊断 18~45 次)。实测深放大区间(2.045ms 一大格)各交互 0.7~8.7ms/帧,像素差 ≤0.017(除亚像素平移),静态/结算帧像素精确。 | | 2026-10-12 | DeepSeek-V4.1-Flash | 修复用户报告的「缩放后平移出现拖影/残留/错误元素」与「2ms 一大格区间仍然卡顿」。根因:①网格纵向滚动量未参与任何内容指纹→滚动时把旧位置像素贴回且每两帧触发一次整幅重绘(≈8fps);②`FindStyleOnlyLanes` 未校验视界→视界变化后仍走局部补丁;③宽基底用含装饰的整幅画面初始化→装饰随平移重影;④装饰缓存的矩形列表与坐标原点两帧不同源→装饰消失/黑块/错位。修复后交互帧 9~19ms → **4.5~6ms**,纵向拖动残留 2 万+ 通道 → 72 通道(亚像素量化),静态/结算帧像素精确。新增 `perf.exe ... 6` 诊断模式(逐帧计时 + 逐像素对比 + 差异包围盒)。未实现:每网格双缓冲后台渲染(需先把 `PaintLane` 改造成无状态渲染器,已在文档 9.15 说明)。 | | 2026-10-12 | DeepSeek-V4.1-Flash(**起源设备**) | **跨设备记忆合流**:把起源设备(本机)的详细工程记忆**并入本文件**(同一记忆文件,不再另建 `agent_mem_origin.md` 等旁支文件),新增 §11(跨设备协作须知)与 §12(起源设备详细记忆原文)。远端 `gitea/main` 已快进合并到本机(`7608e85` → `f97c4cf`,**16** 个提交)。本机 MinGW-w64 GCC 15.2.0 (UCRT) 下 `make release` 通过。 | | 2026-10-12 | DeepSeek-V4.1-Flash(**起源设备**) | **修复「缩放后平移出现严重绘制错误/波形抖动/前台被拖慢」**:根因是后台图层线程会改写共享渲染状态(保存→改写→恢复`m_area`/`m_plotRect`/视界/网格矩形),与 UI 线程无锁的平移/命中测试相冲 → 视界被弹回、网格错位;叠加"全局锚点 + 逐网格内容"导致各网格时间错位。修法:**移除后台图层线程**(改为 UI 线程同步维护)、**逐网格独立锚点与滚动**(补细条用该图层自己的 `wideMin`)、跨度变化时同步 `RebuildWideBuffer()`。同时把「弱波增强」改名「**弱峰增强**」并改为 **0~200% 分段插值**(0% 完全不动 / 100% 到较温和一侧 / 200% 完全拉平;新 1.5 = 旧 0.5)。离屏验证:居中图层 vs 整幅重绘 **5 网格逐像素 0 差异**、平移帧对齐 ≤1px、24 次竞态检测 0 覆盖;测试套件 **380/380 通过**。 | | 2026-10-12 | DeepSeek-V4.1-Flash(**起源设备**) | **修复"按住键拖拽平移时波形质量下降 / 断线 / 周期性卡顿"与"浮动面板互相覆盖时交互错位"**,并把弱峰增强改为**逐信号参数**。① 平移:`RollWideBuffer` 不再整块重画(只平移+补细条,内容过期/锚点过远只入队),新增 `RenderPendingLaneRebuilds(budget)`(**每帧最多重画 1 个网格**,其余下帧)+ `BlitWideBuffer` **局部贴图**(图层没覆盖的部分留空,不再整块跳过);"结算"整幅精确帧**仅限非拖拽且全部图层就绪**(消除拖拽中每两帧的整幅重绘/质量振荡)。实测 `VERY_BIG_DATA` 5 网格深放大平移:中位 **6.2ms**、max 7.6ms(无尖峰);按住键且视界停在边界时 **0.29ms/帧**。② 弱峰增强:`WeakPeakItem` 改为携带 `levelPercent(0–200)+iterations`,面板改为与均值滤波同构的**逐信号两个输入框**(删除网格级两滑块),哈希/快照/构建/预览全部改为逐信号。③ 浮动面板:新增 `m_panelOrder` 层序,**命中从最上层往下**、点击 `BringPanelToFront`、新面板置顶;修复"上层 ✕ / 标题栏被下层参数框抢走"。离屏测试 **388/388 通过**。 | | 2026-10-12 | DeepSeek-V4.1-Flash(**起源设备**) | 弱峰增强的参数输入改回**逐信号滑块**(每信号两条轨道:增强等级 0–200% / 迭代次数 N),并让「迭代次数 N」**上限可就地编辑**:点击滑块右侧数值 → 整行变成输入框(`EditFilter::BYTE` 只收数字、空内容显示灰提示当前上限、`0`/超限/空 保持原值、下调后当前迭代次数自动被夹到新上限);上限进 `PanelSlotKey` 但不进 `SceneContentHash`(不影响波形重画)。离屏测试 **409/409 通过**。 | | 2026-10-12 | DeepSeek-V4.1-Flash(**起源设备**) | **修复"斜向/不规则拖拽自由移动视界时的卡顿、掉帧与波形错误绘制"、"预览曲线不如普通信号细腻(无抗锯齿)"、"拖动参数滑块时预览曲线跳帧闪烁"**。① 根因:拖拽平移的**纵向分量**会逐帧小改被拖网格的 `yMin/yMax`,而**网格幅值范围同时参与「宽基底可用性」几何指纹与每个网格的图层键**,于是每帧都退化成整幅重绘(`m_fastFrame` 关抗锯齿)+ 5 个网格全部入队(一帧只补 1 个)→ 质量下降、卡顿、图层相互不一致(断线)。修法:`SceneContentHash` 新增 `includeLaneRange`(`SceneGeometryHash()` = `false,true,false`,幅值不进"能不能走快路径"的判定),幅值改由**逐网格** `LaneLayerHash(laneIndex)`(`LaneLayerKey` 的基)承担 → 只让真正变化的那个网格失效;并把 `RenderPendingLaneRebuilds(1)` **移到 `BlitWideBuffer` 之前**(本帧补好的网格本帧就可见)。② 预览曲线抗锯齿不再因交互(`m_fastFrame`)关闭(仅保留"陡峭段过多"这一条形同无声的逃生口)。③ `AcquireNewestCurve` 改为**只按「信号 + 曲线种类」匹配**(参数无关,优先挑覆盖所需区间的条目)→ 拖参数滑块时用上一帧的曲线顶替,后台算好再无缝替换,**不再闪烁/消失**。离屏验证:斜向拖拽 40/40 帧走宽基底快路径(中位 9.2ms,无尖峰),参数连改 10 次 **0 个空白帧**,抗锯齿回归断言(快帧混淆边缘像素 8149/8404);`uitest` **420/420 通过**、`panperf` PASS、`layercheck` 保持"0 次视界覆盖 / 平移对齐 ≤1px"。 | | 2026-10-12 | DeepSeek-V4.1-Flash(**起源设备**) | **修复"深放大下左右平移流畅、上下/斜向平移卡顿"(用户猜测:上下没有预渲染机制——已证实)与"深放大时拖拽光标跑飞"**。① 量化复现:`panperf` 新增纯竖直拖拽 + 逐帧统计**图层重画次数**,并在 5 网格全开 4 类悬浮面板(20 个)后对比 —— 竖直拖拽中位 **166.6ms/帧**、水平 **37.7ms/帧**(4.4 倍差距,用户报告的"上下卡顿"正是它)。② 主因是**面板像素图层被幅值范围击穿**:`PanelGlobalKey()` 计入 `visual.yMin/yMax`,平移的纵向分量每帧小改幅值 → 整层 20 个面板每帧重画(去掉后 166.6→**24.8ms**);次因是**图层没有纵向预渲染余量**(幅值一变就整块重画,实测 40 帧里 40 次 → 修复后 **1 次**)。③ 修法:`LaneLayer` 增加 `marginY/anchorYMax/pxPerValue`,幅值范围按**像素行**精确扩展(上下各半个网格高)、贴图时按 `(anchorYMax - 当前 yMax) * pxPerValue` 取纵向偏移,图层指纹只计幅值**跨度**不计绝对范围;幅值刻度改以**前台可见网格**的跨度/高度取 `NiceStep`(保证与整幅渲染逐像素同源);网格分界线移出图层(改由宽基底路径的 `PaintLaneDividers()` 补画,否则纵向偏移会把它带走)。④ 光标:深放大下平移会把时间光标甩到视界外很远(像"跑飞/丢失")—— 新增 `ClampCursorsAfterPan()`,在**平移**(中键/左键拖拽、Shift+滚轮、方向键)之后夹取;**离散**的视图变化(选区放大/适应视图/按网格时间轴展开)仍然不移动光标;抓取被钉在边缘的光标读数时不再沿用抓取偏移(否则首帧会跳到视界外再被夹回边缘)。验证:`uitest` **432/432**(新增 `[AI]`:贴边读数抓取后光标落在鼠标处误差 0.0px、中键平移 1.5 屏 0 帧出界且跟手)、`panperf` 竖直 3.8~5.0ms(40 帧仅 1 次图层重画)、水平 6.5ms 持平、`layercheck` 0 视界覆盖、新增探针 `.preflight/cursorcheck` 30/30。 | | 2026-10-12 | DeepSeek-V4.1-Flash(**起源设备**) | **修复"纵向预渲染带出的绘制异常/抽搐"(中键上下拖动、尤其拖到网格之外时),并把「弱峰增强」升级为「弱峰调整」(幅度 -200% – +200%,负值削弱)**。① 绘制异常根因:上一轮给图层加了纵向余量,但"偏移超出余量"仍走**排队整块重画**(一帧只补 1 个网格)→ 重画完成前 `BlitWideBuffer` 只能贴出部分行、**其余留空**(网格内出现空白条带),且重画与贴图的锚点不同源 = "波形没按预期移动";鼠标拖到网格外很远时每帧都触发 → 抽搐。修法:与横向**同构的纵向滚动**(BitBlt 整块滚动 + 只补画暴露的一整条,`anchorYMax -= roll/pxPerValue`),并且**必须先于横向补条**;滚动量**量化**(`kLayerRollQuantum = 16` 像素,不足量子交给贴图 `srcY` 偏移)以保持画布映射稳定、让折线缓存命中(未量化时每帧滚 1~30 行 → 折线每帧重算)。验证:`panperf` 拖出网格 900px 时被拖网格的**空白行数与整幅重绘逐帧一致**(64 vs 64,即无空白条带),竖直拖拽 16 帧仅 2 次图层重画。② 弱峰调整:`ComputeWeakPeakAdjust`(原 `ComputeWeakPeakBoost`)+ `WeakPeakItem.amountPercent`(-200–200,默认 150),面板「增强等级 %」→「调整幅度 %」、滑块量程 -200%–+200%(**0 在正中间**)、数值列加宽以容纳 "-200%";识别规则与旧实现完全一致,正幅度段与旧实现**逐位相同**,负幅度段改为以相邻**异类**极值(波峰看相邻波谷)为参照:-100% 拉到**较近**的那个、-200% 拉到较远的那个(压平/抹除,左右平滑过渡),且**不得反向增强**。离屏验证:`uitest` **443/443**(正幅度 0/50/100/150/200% → 550/555/560/580/600 逐位保持;负幅度 -50/-100/-200% → 峰 500/450/400、谷 500/550/560;超范围夹取;健康峰不动)、`panperf`/`layercheck`/`cursorcheck` 全过。 | | 2026-10-12 | DeepSeek-V4.1-Flash(**起源设备**) | **修复"上下拖动之后,左右/上下/斜向拖动都出现绘制错误"(真因:图层条带渲染时 GDI 网格线没被限制在条带内),并调整「弱峰调整」的弱峰/弱谷定义、默认幅度与数值符号显示**。① 根因:`PaintLane` 的 GDI 网格线裁剪矩形只限制了横向(`RectF(m_area.X, clipTop, m_area.Width, …)`),而**纵向补条是全宽 × 窄 y** —— GDI 绘制不受 GDI+ 裁剪约束,于是横向网格线被画到条带之外的已正确像素上、**覆盖在波形之上**,并**永久烘进图层**,随后每次平移都带着它一起漂移(用户看到"到处都有绘制错误")。修法:`laneClip = IntersectRect(m_area, 网格可见矩形)` 同时用于 `SetClip` 与 GDI 裁剪。② 新增离屏探针 `.preflight/dragcheck`(真文件 + 真中键拖拽消息:先上拖半个网格高,再左右/上下/斜向、最后拖出窗口;每帧把图层画布的有效条带与"同一 `wideMin` 的新渲染画布"逐像素比较):修复前被拖网格整行不一致(2410px ≈ 2 行全宽),修复后 ≤171px(仅补条边界的抗锯齿边缘)。③ 弱峰调整:**「是否偏弱」改为**"波峰必须同时低于左右两侧各自的(相邻波峰+相邻波谷)均值;波谷反之";默认幅度 **0%**(不改动);滑块右侧数值**始终带符号**(`+150%` / `-150%` / `0%`,验证 +150% 与 -150% 墨迹宽度一致且都比 `0%` 宽 ≥8px)。验证:`uitest` **441/441**、`dragcheck`/`panperf`/`layercheck`/`cursorcheck` 全过、`make clean && make all` 零警告。 | | 2026-10-12 | DeepSeek-V4.1-Flash(**起源设备**) | **「弱峰调整」升级为三组识别规则 + 滑动条 ±400% 梯度驱动**:第一类(两侧均值都要满足,原规则)在滑动条 0–200% 内走完自身 0–200%;第二类("或":任一侧均值满足即可)从 100% 起参与,own 幅度 = clamp(|a|-100%,0,200%);第三类(只与左右同类极值比较,最宽松)从 200% 起参与,own 幅度 = clamp(|a|-200%,0,200%);负值完全对称。每组自身的 ±200% 映射统一为:+100% → 左右同类极值中较温和的一侧、**+200% → 左右同类极值的均值**(不再取较大的那个);-100% → 相邻异类极值中较近的一个、**-200% → 相邻异类极值的均值**(不再取最小的那个)。同时满足多组时取**幅度绝对值最大**的组。验证:`uitest` **463/463**(第一类点 0/50/100/150/200% → 520/570/620/**625/630**;只满足第二类的峰 130 → 100% 不动、150%/200%/250%/300%/400% = 140/150/162.5/175/175;只满足第三类的峰 195 → 200% 不动、250%/300%/400% = 197.5/200/200;±400% 夹取;滑块中间 = 0%、两端 = ±400%)、`panperf`/`layercheck`/`cursorcheck`/`dragcheck` 全过、零警告。 | | 2026-10-12 | DeepSeek-V4.1-Flash(**起源设备**) | **修复"按住中键把视界纵向移动好几个网格高(或横向拖很远)后,出现大量绘制错误与持续抽搐"**:根因是 `RollWideBuffer` 对"位移超出余量"只做**排队等后续帧重画**(`QueueLaneRebuild` + `continue`)—— 该帧 `BlitWideBuffer` 取不到源行 → 网格里出现**空白条带**;且每帧都重新入队而 `RenderPendingLaneRebuilds(1)` 一帧只补 1 个网格 → **补不完 = 持续抽搐**;纵向余量只有 ±半个网格高,正常速度的纵向拖拽每帧就超过它,故几乎必现。修法:**只有"内容真的变了"才排队**,距离类问题一律本帧就地解决 —— 位移小于画布则滚动(O(1) BitBlt + 只补暴露细条),超出画布则**立刻整块重画并重新居中**(0.4ms/网格)。验证:新增探针 `.preflight/dragcheck` 按用户要求**全程按住中键不放**地做检查(图层条带逐像素 / 画布覆盖率 / 画面空白行数与墨迹占比),并把纵向滚动临时关掉做 A/B:立刻报 `layer band diff 76221 / drawing errors 11`,恢复后 **0 错误**;`uitest` **463/463**、`panperf`/`layercheck`/`cursorcheck` 全过、`make clean && make all` 零警告。 | | 2026-10-12 | DeepSeek-V4.1-Flash(**另一台设备**) | **修复「垂直中键拖拽后再平移,网格内容纵向跳变 / 绘制错误」**(详见 §9.16):根因是 `RenderLaneLayerRegion` 结束时**无条件**把 `layer.anchorYMax` 重置为"当前 `yMax`"—— 对整块重画 / 纵向补条成立,但**横向补细条(只改一条竖带、其余像素仍在原锚点)**不成立。纵向拖动以 `kLayerRollQuantum=16px` 量化,停下时图层会残留 `<16px` 的纵向偏移且 `anchorYMax != 当前 yMax`;第一次水平微移就把该偏移**静默清零**(`vOffset→0`)而像素未动 → 整个网格波形纵向错位(最多 15px)。修法:`RenderLaneLayerRegion` 新增形参 `anchorYMax`,内部以它作幅值映射基准(`yMin = anchorYMax - laneSpan`)渲染并写回 `layer.anchorYMax`;横向补条传 `layer.anchorYMax`(保持不变),整块重画/纵向补条传当前(或滚动后)`yMax`。`vOffset==0` 时新旧逻辑完全等价。验证:新增离屏探针 `.preflight/perf/pancheck`(窗口 `SW_HIDE` + 注入消息 + 读图层内部 `anchorYMax`/`vOffset` + 持按钮帧 vs 释放后整幅帧的纵向互相关):修复前 A 场景 `dy=+15px`(lane0 差异 39245),修复后 **`dy=0`**(差异 1220,仅已知列相位残差);对照/中间网格/长距水平/斜向/松开重按共 7 个场景在缩放 0/8/26 步下全部 `dy=0`(中间网格 `+1px` 为整像素量化残差);`make clean && make all` 零警告。 | | 2026-10-13 | DeepSeek-V4.1-Flash(**另一台设备**) | **交互流畅性三项修复**(详见 §9.17):① **光标不再跟随视界**——删除 `ClampCursorsAfterPan()` 及其在平移路径(中键/左键拖拽、Shift+滚轮、方向键)的三处调用,视图变化时光标时间位置**完全不变**(可滑出视口),`ClampCursorToView()` 只在拖拽光标时使用(mode 14:平移 20 帧后 `CursorTime` 不变且 `offViewport=1`)。② **缩放首帧不再卡顿**——根因是跨度变化时 `Paint` 会**同步重建全部 5 个网格图层**(实测 70~80ms)+ 随后整幅结算(40ms)=单次缩放 ~110ms 卡顿;修法:`BlitWideBuffer` 对跨度过期的图层改用**旧像素 `StretchBlt` 拉伸占位**(不再跳过导致空白)、`Paint` 只在"图层数量不符"时同步重建(新增 `LayersAllocated()`)、`RenderPendingLaneRebuilds` 允许按新跨度重建、且**只在视界稳定后**才花预算精细重绘(`viewStopped` 门控);实测首帧 **70~80ms → ~10ms**。保留"结算"帧(实测宽基底路径与整幅重绘有 17 万+ 像素列相位差,非同源)。③ **超大信号拖预览滑块**——量化发现开销**不在绘制**(`PaintLaneDerivedOverlays` 仅 0.375ms),而在"参数每帧变化"触发的**整条信号**(2576 万采样)预览重算调度;新增 `kHugeSignalPreviewSamples=4000000`,超大信号拖拽期间不再每帧排队重算(15.0→13.7ms,**剩余 ~8ms 根因未完全定位**)。验证:`pancheck` 7 场景全 `dy=0`、`zoomcheck` mode 0/13/14、`make clean && make all` 零警告。 | | 2026-10-13 | DeepSeek-V4.1-Flash(**另一台设备**) | **深放大中键拖拽卡顿(根因=GDI+ 抗锯齿"满幅锯齿"病态)+ 横向补条裁剪 + 并行网格图层(B/C,详见 §9.18)**。① **B 拆解**:该视界(`[10.2719,10.2831]s`)下 `RollWideBuffer` 补条重绘 **13.08ms**,其余 ≤0.8ms —— 瓶颈全在补条重绘。② **补条裁剪**:横向补条只覆盖窄竖带,却按整条宽窗口构建/描边整段折线;改为按条带 x 范围裁剪采样区间(旁路折线缓存),`RollWideBuffer` **13.08ms → 1.73ms**,拖拽整帧 13~24ms → ~3ms,且**逐像素 0 差异**。③ **病态根因**:拖拽超出预渲染余量时走整块重画,**整宽单网格渲染 655ms** —— 同 4481 点 `DrawLines` 实测 **AA 694ms / 无 AA 29ms(23 倍)**(编码器方波在 1~2 采样/像素形成每列整幅跳变的锯齿,原实现此时既不走包络也不关 AA);修法:**包络带阈值从 2 采样/像素降到 1**,并扩展"梳状(关 AA)"判据(多数列列内跨幅 >0.5×网格高 / 相邻列整幅跳变)——**几何一字未改**;整宽渲染 **655→47.7ms**、补条 6.9→2.4ms、拖拽 200 帧 **avg 4.5 / max 5.8ms / 0 帧 >8ms**。④ **C(用户解除多线程限制)**:实现 `RebuildLaneLayersParallel` —— 每线程一个**隔离 `PlotView`**(自有 `m_workerTab`/`m_area`/`m_plotRect`/`m_lanes`)渲染不同网格,主线程 join 后按真实下标重算 key;并行 vs 顺序**逐像素 0 差异**;⚠️ 踩坑:`BuildLaneVisuals` 在 `m_tab==nullptr` 时清空 `m_lanes`,故**必须给 worker 一个非空的自有 tab**。**诚实结论:并行仅 1.03x**(同机 4 线程 CPU 真并行 3.98x → GDI+ 光栅化被串行化),故**并行对光栅化无收益**,仍保持"视界停后每帧补 1 个网格"。验证:`dragperf` mode 0/1/3/5/6/8/9、`pancheck` 全 `dy=0`、`zoomcheck` 无回归、`make clean && make all` 零警告。 | | 2026-10-14 | DeepSeek-V4.1-Flash | **装饰层改为带 alpha 的独立图层(消除 GX/图例外围"直角白边")+ 图例圆角托底与悬停/选中配色统一 + 预览曲线内存释放(详见 §9.20)**。① **白边根因**:装饰层原为"从后备缓冲区抓取一块**不透明矩形**",圆角之外(属于波形)的像素被一起烘进缓存,为让其跨视界可复用又在圆角外铺了直角不透明底色 ⇒ 密集波形上就是一圈直角白边。**修法**:`m_decorDc` 改 **32bpp DIBSection**、新增 `RenderDecorCache()` 把装饰**单独绘制**进全透明位图(不再抓取)、`BlitDecorCache()` 改用 **`AlphaBlend`**(`AC_SRC_ALPHA`,`GetProcAddress(gdi32!GdiAlphaBlend)` 解析、不新增链接依赖)⇒ 圆角外透出**当前**波形;并做 **alpha 修补**(GDI 文本会把写入像素的 alpha 清 0,不修补文字会变透明洞);删除全部外扩直角底色。② **图例**:逐条目圆角托底(与 GX 徽标同风格),**选中态 = 悬停态配色**(`accentSoft`+`accent`+`textStrong`),悬停在不透明托底上重绘色块/文字并保留曲线光晕;`LegendHit` 新增 `radius`;补齐「+N」/「(无可见信号)」提示的装饰矩形。③ **内存**:`thread_local` 工作缓冲**保留容量封顶**(`kReuseBufferMaxElements` + `ReuseBufferScope`,超大计算用完即释放)+ 关闭最后一个预览功能时 `TrimDerivedCurveDataIfIdle()`(`PauseDerivedAsync` 让线程退出、thread_local 析构归还内存)—— 实测 1792MB → **关闭后 1203MB**(修复前不下降)。④ 性能:`BlitDecorCache` **0.19ms/帧**、`RenderDecorCache` 2.4ms(仅内容变化时);`zoomcheck` mode 13 收敛帧与整幅重绘**逐像素 0 差异**(此前 3503)。验证:离屏 `verify.exe` **49/49 通过**、`make release` 零警告。 | | 2026-10-14 | DeepSeek-V4.1-Flash | **修复远端「调整」功能引入的性能回归 + 根治首帧卡顿 + 图例交互/托底重做(详见 §9.19)**。① **8 倍回归**:远端把 `DataSet::Style(ref)` 写进了 `AutoRangeLaneToView` 的逐采样循环(亿级解析调用)→ 工具栏「Y 轴自适应」在全视图下 **1200ms**(旧提交 147ms);修法:样式指针提循环外 + **整段自适应取已缓存 min/max(O(1),结果逐位一致)** + 无变换走紧凑循环 → **0.02ms**。② **首帧卡顿根治**:加载后宽基底图层从未分配,用户第一次平移/缩放时 `Paint` 在那一帧**同步重建全部 5 个网格图层**(首帧 **64.3 → 13.0ms**);修法:新增 `WarmLaneLayersStep()`,在**命中静态场景缓存**(视界未变、非拖拽)时用预算机制**每帧至多补 1 个网格**,把整批重建摊到静止帧。③ **派生信号把「调整」烘进数据**(不再继承样式指纹);⚠️ 当时写的“仅 `offsetTime` 因共用文件时间轴仍需继承”**已被 §9.21 推翻**(现在 `offsetTime` 也按 `time[i]-offsetTime` 重采样烘进采样值)。④ **GX 拖拽让位判据改为鼠标指针**(鼠标越过相邻网格中心水平线即让位),并给 GX 徽标加悬停高亮(叠加层 + `IDC_HAND`,与信号列表 GX 徽标一致)。⑤ **图例命中矩形在装饰缓存路径下必须重建**(原先命中缓存时提前 return → `m_legendHits` 被清空且不重建 → 中键平移后图例悬停/点选全失效)。⑥ **每个图例条目加圆角托底 + 装饰矩形改为逐块 + 托底不透明铺满外接矩形** → 消除「GX/图例周边旧波形像素残留」(逐像素验证装饰矩形内 **0 差异**);悬停高亮为不透明托底并**重绘色块与文字**(否则内容被盖住)。验证:离屏 Application harness **40/40 通过**,`zoomcheck` mode 20–26 量化,`make release` 零警告。 | | 2026-10-14 | DeepSeek-V4.1-Flash(**起源设备**) | **边沿计数改用「绘制值」+ 派生信号烘入时间偏移 + 图例托底统一为 GX 徽标款 + 调整二级面板向上展开(详见 §9.21)**。① **边沿计数恒为 0**:`EdgeCountsFor`/`EdgeCenterCountsFor` 只比原始采样值,而阈值是屏幕上的值 → 「系数 50」的信号统计恒为 0;修法:判定值改为 `ApplyDrawTransform(style, values[SourceIndexFor(...)])`(中心轴两侧同套变换;水平镜像时曲线窗口也要先映射下标);缓存新增 **`styleKey`**(否则改完「调整」仍命中旧计数)。② **区间边界点被误计**:`EdgeIndexRange` 左端“多取一个采样点”的外扩**已删除**,收紧为“**两个端点都在区间内**才计入”(光标恰落在越过阈值的点 B 上时 B 不再被算作上升沿)。③ **`offsetTime` 也烘进派生采样值**(**推翻 §9.19 的“唯一例外”**):按 `time[i]-offsetTime` 取最近采样点重采样(单调时间轴用只前进的游标,O(n);越界置 NaN),新信号样式**全默认**;非单调时间轴退回到把 `offsetTime` 带在样式上。④ **图例托底 = GX 徽标同款**(同高 / 圆角 = 高度一半 / 同配色 / 水平内边距 = 胶囊半径),文本改用新增字体 **`uiTiny`(10 逻辑 px)**;`kLegendPlateRadius` 已删。⑤ **`PopupMenu::Open(..., expandUp=true)`**(锤点为**左下角**、向上展开,`m_buildExpandUp` 供 `RebuildLayout` 复用)+ 「系数/偏移」「镜像」面板锤在按钮左上角再上移 `kAdjustMenuGap`。验证:`.preflight/uitest` 新增 `[AR]`(五组断言全部与**独立参考实现**逐位比对,含 A/B:“按原始采样值算会得到 7/6 个边沿”);**并同步修正 harness 里两处旧规则断言**(`[AP]` 派生信号继承 → 默认样式 + 烘值;`[AN]` 图例带无托底 → 装饰矩形逐块且暴露列可见底层内容);合计 **722/722 通过**,`make release` 零警告。 | --- ## 11. 跨设备协作须知(起源设备视角) > **本文件是唯一权威记忆**。所有设备 / Agent 共用它,**不要再新建 `agent_mem_origin.md` 等旁支记忆文件**。 > §1–§10 = 另一台设备维护的**精简权威备忘**;§12 = 起源设备(本项目**从零构建**所在的那台机器,即本机) > 的**详细工程记忆原文**(逐项功能与交互硬约束、UI 坐标、验证脚本、性能实测、Git/终端陷阱)。 > 三者同处一个文件,便于任何 Agent 一次读全。 ### 11.1 两端工具链差异(重要) | 维度 | 起源设备(本机) | 另一台设备 | | --- | --- | --- | | 编译器 | MinGW-w64 **GCC 15.2.0**(x86_64-win32-seh rev1,**UCRT**) | MinGW-Builds **16.1.0**(seh-rev0) | | 运行时 / 内存 | UCRT;`std::thread` 实测**真并行**(2 线程 1.92x) | 见该设备记录 | | 显示缩放 | **125%**(DPR 1.25,窗口边框偏移 `(9,38)`) | 见该设备记录 | | Makefile | **相同**(`-lgdiplus -lcomdlg32 -ld3d11 -ldwmapi -ld3dcompiler`),两端 `make release` 均通过 | 同左 | - 不要依赖"只在某一端成立"的编译假设;任何改动后至少在**本机**跑一次 `make release`。 ### 11.2 高风险硬约束摘要(均来自 §12 实测;违反必出缺陷) 1. `PlotView::Paint()` 分支顺序**固定**:`LANE_REORDER` → 静态场景贴图 → 每网格图层(宽基底) → 样式补丁 → 整幅重绘。**勿改序**。 - 场景贴图必须在**图层之前**(否则鼠标悬停每两帧触发一次"结算整幅重绘",反而更卡); - 图层必须在**样式补丁之前**(拖弱波增强 / 均值滤波参数只改派生曲线,走图层更快且质量更高)。 2. 每网格图层**只含基底**(底色 + 网格线 + 波形 + 派生曲线),**不含装饰**(Y 轴刻度 / 图例 —— 它们锚定网格左缘、不随时间视界移动)。 3. 图层位图宽度 = `width + 2*max(1, width/2)` ≈ **2 × 视界宽**(左右各半屏,可平移 ±半屏)。 4. `RenderLaneLayerRegion` 结束后**必须** `BuildLaneVisuals()`(重算 `m_laneContentHeight`)**再**还原 `LaneScroll` 与各网格 `yMin/yMax`;漏还原任何一项 → 算出的图层指纹与实际渲染不符 → 每帧重复重画(白耗性能;旧版有后台线程时会死循环卡死)。 5. ⚠️ **图层渲染默认在 UI 线程内同步完成**,但**多线程渲染「已获用户明确解禁」**(2026-10-13)。要点: - 渲染函数(`RenderLaneLayerRegion` / `PaintLane`)会临时改写 `m_area`/`m_plotRect`/视界/网格矩形,**共享这些状态的并发渲染会互相覆盖**(视界弹回、命中错位)—— 这正是 2026-10-12 移除"后台图层线程"的根因。 - **允许并行的唯一安全姿势:每个工作线程持有"隔离的渲染上下文"**。已落地的实现见 `PlotView::RebuildLaneLayersParallel()`:为每条线程准备一个**隔离的 `PlotView`**(`m_tab` 指向它**自有的** `m_workerTab` 副本,`m_area`/`m_plotRect`/`m_lanes` 都是它自己的),各自把不同 `laneIndex` 的图层位图渲染到**不同的 `m_layerFront` 元素**(互不重叠),主线程 join 后按**真实网格下标**重算 `LaneLayerKey`。 - 实测:并行结果与顺序渲染**逐像素 0 差异**(5 网格全部 0)。⚠️ 但**本机 GDI+ 光栅化会被串行化**(4 线程 CPU 真并行 3.98x,而并行渲染 5 网格仅 **1.03x**)→ 光栅化并行**没有收益**,因此**不要把"逐帧预算 1 个网格"改成"一帧补齐全部"**(那只会把一帧从 ~10ms 抬到 ~31ms)。并行仅用于"本来就要一次画完"的 `RebuildWideBuffer()`。 6. 每个图层记录**自己的**时间映射 `LaneLayer::wideMin`;滚动与贴图必须**逐网格**按各自 `wideMin` 计算,**绝不共用一个全局锚点**(否则各网格被平移不同的真实距离 → 网格间时间错位)。补细条时必须传 `layer.wideMin`(不能用"当前视界"的映射,否则有 ≤0.5px 接缝与抖动)。 7. 加锁顺序**恒定** `m_renderMutex`(recursive)→ `m_layerMutex`(图层不再有跨线程访问,但顺序约定保留)。 8. ⚠️ **前台刷新线程绝不做“整批图层重绘”**:平移/拖动期间 `RollWideBuffer()` **只**做逐网格整数像素平移 + 补细条;内容过期 / 锚点过远的网格只登记到 `m_layerRebuildPending`,由 `RenderPendingLaneRebuilds(budget)` **本帧至多重画 1 个**,其余下帧继续(并 `PostMessage(kMessagePlotDerivedReady)` 请求重绘)。图层没覆盖到的部分 **`BlitWideBuffer` 只贴有像素的那一段**(其余留给底色,下帧补齐),**不得整块跳过**(跳过 = 网格变空 / “断线”)。 9. ⚠️ **“结算”(整幅全质量精确帧)只能在 `m_dragMode == NONE` 且全部图层就绪时发生**:否则按住键拖拽时会在“宽基底快路径”与“整幅重绘(拖拽时关 AA)”之间每两帧振荡 → **波形质量轮流变差 / 抖动 / 周期性卡顿**(用户报告的“按住拖拽时显示效果下降、出现断线,松手才恢复”)。 10. **GDI 绘制不受 GDI+ `SetClip` 约束**:`UiText`(`DrawTextW` / `ETO_CLIPPED`)与 `UiFillRect` 的 GDI 快路径(不透明且面积 ≥ 20000px²)都会画出裁剪区;宽矩形 / 文本必须**自己夹取**,GDI 批绘必须显式传 clip。 11. `SceneContentHash` 必须覆盖 `Tab().LaneScroll()`(网格纵向滚动量)与 `SelectedRef()`;`FindStyleOnlyLanes()` 必须**逐项比较 `SceneCacheKey`**(`SceneGeometryHash()` 刻意不含视界,只比它不足以判定"视图没变")。 12. 随鼠标变化的绘制(悬停高亮 / 光晕 / 图例高亮 / 光标读数 / 选区 / 提示 / 面板悬停)**必须同时**加在"缓存贴图快路径"与"整幅重绘路径"两侧。 13. 浮动面板必须按**层序**(`m_panelOrder`,队尾 = 最上层)绘制与命中:命中从后往前取第一个命中者,点击面板 `BringPanelToFront()`,新开面板追加到队尾。**不要再按 `kEdgePanelKinds` 固定顺序命中再 break**(否则上层 ✕ / 标题栏会被下层参数输入框抢走 → “上层面板关不掉 / 拖不动”)。 14. ⚠️ **多线程限制已解禁(2026-10-13),但必须"隔离状态"**:波形渲染可以多线程 —— 前提是每个线程用**自有的渲染上下文**(自有 `m_tab`/`m_area`/`m_plotRect`/`m_lanes`/DC),绝不共享可变渲染状态(见第 5 条的安全姿势)。已落地的并行只有:派生曲线线程池(`m_curveWorkers`)与并行网格图层渲染(`m_layerWorkers`)。⚠️ 实测**光栅化并行无收益**(GDI+ 串行化),所以并行只在"整批重建"这类本来就要一次画完的地方用。任何"移动 / 释放采样"的操作前必须 `PauseDerivedAsync()`。 15. MinGW 的 `swprintf` 按 C99:宽格式串里的 `%s` = **窄**字符串,宽字符串必须用 `%ls`(否则 "KB" 会被截成 "K")。 16. 源文件名必须**全局唯一**(Makefile 按 basename 去重 + vpath 查找);新增目录要同时登记进 `CXX_INCLUDES_PATHS` / `CXX_SOURCES_PATHS`。 17. ⚠️ **`m_legendHits` 只在"整幅重绘"路径开头被清空**,因此 `PaintLaneLegend` 在**任何**绘制路径 (**包括装饰缓存命中、提前 return 的那条**)都必须**先重建命中矩形**再决定是否跳过绘制 —— 否则清空后不再填充,图例的悬停高亮 / 点击选中 / 双击改名会**永久失效** (用户报告"用中键平移过视界后图例点不动")。同理 `m_editFieldRect` 也必须在几何段设置。 18. ⚠️ **装饰缓存的像素必须与时间视界无关**:装饰位图的贴回边界由 `floor/ceil(rect)` 得出, 因此每块装饰(GX 徽标 / 图例托底 / Y 轴胶囊)都必须画成**不透明的圆角形状本身** (`RenderDecorCache` + `AlphaBlend` 方案下圆角之外**保持全透明**,不要再用外扩的直角不透明底色)。 装饰矩形也必须**逐块**(禁止覆盖整条图例带)。验证方法:宽基底实时帧 vs 同视界从零重绘, **装饰矩形内必须 0 差异**。 (历史:早期实现是 `CaptureDecorCache` 从后备缓冲区抓不透明矩形 —— 该函数**已删除**。) 19. ⚠️ **逐采样循环里禁止调用 `DataSet::Style(ref)` / `Resolve()` 等按下标解析的函数**: `AutoRangeLaneToView` 曾因此把全段自适应从 147ms 劣化到 **1200ms(8 倍)**。 一律把指针提到循环外。整段(`begin==0 && end==count`)且无变换/无水平镜像时, 自适应应直接取 `SignalData::minValue/maxValue`(O(1),与原逐点扫描逐位一致)。 20. ⚠️ **"整批图层重建"绝不允许发生在交互帧里**:新增 `WarmLaneLayersStep()`, 在**命中静态场景缓存**(视界未变、非拖拽)时用既有预算机制**每帧至多补 1 个网格**并自请求下一帧。 否则"加载后的第一次平移 / 选区放大"那一帧会同步重建全部网格图层(首帧 64ms → 预热后 13ms)。 21. ⚠️ **派生信号必须"烘值 + 默认样式"**(推翻 §9.19 的"`offsetTime` 仍需继承",见 §9.21): 「调整」的**全部**项(含 `offsetTime`)都写进派生采样值,新信号样式一律默认; 否则「调整」面板会带着一整套继承来的指纹,或新信号与预览横向错位。 只有"无法烘值"的退化情形(时间轴非单调 / 时间列缺失)才退回把 `offsetTime` 带在样式上。 22. ⚠️ **一切"按屏幕上的值"做的统计/判定都必须用 `ApplyDrawTransform` 之后的绘制值**: 横向光标阈值、边沿计数(`EdgeCountsFor` / `EdgeCenterCountsFor`)、Y 轴自适应都属此类。 用原始采样值判定会得到"应用过「调整」的信号统计恒为 0"这类完全错误的结论。 这类**缓存**的键必须带**样式指纹**(`EdgeStyleKey()`),否则改完「调整」仍命中旧结果。 23. ⚠️ **限定统计区间时,"区间之外"的采样一律不参与**:只有**两个端点都在区间内**的采样对才计入。 不要在区间左端"多取一个采样点"来照顾跨边界的边沿 —— 那会把选区之外的那一段也算进来 (用户报告:"光标正好落在越过阈值的点 B 上时,B 被错认成上升沿")。 24. ⚠️ **图例托底 = GX 编号徽标同款**(同高 / 圆角 = 高度的一半 / 同配色 / 水平内边距 = 胶囊半径), 文本用更小的 **`uiTiny`(10 逻辑 px)**;`LegendHit.rect` 同时是命中矩形与悬停重绘的托底, 看到的必须严格等于点得到的。 25. ⚠️ **`PopupMenu::Open(..., expandUp = true)`** 表示"锚点是菜单**左下角**、向**上**展开"。 给按钮挂"二级面板"时用它(并把锚点设在按钮左上角再上移 `kAdjustMenuGap`); `RebuildLayout()` **必须复用 `m_buildExpandUp`**,否则数值回写触发的重排会让面板跳回向下展开。 ### 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 `(正常应为 `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` 改为 `std::function`, `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 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>`)与 `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`,上限 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 -- ` 在 `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` 自嵌套,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 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 -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`; `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 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&` 的 `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`,**队尾 = 最上层**,`mutable`)。 - `SyncPanelOrder()`:按现有顺序保留仍可见的面板,把**新出现**的面板**追加到队尾**(新面板永不被盖住)。 - `TopPanelAtPoint(x, y, lane, kind)`:**从队尾往前**找第一个命中的面板。 - `BringPanelToFront(lane, kind)`:点击面板(任意部位,含 ✕/标题/输入框)时把它移到队尾 = 置顶。 - 绘制循环改为**按 `m_panelOrder` 自下而上**绘制(与命中顺序、像素缓存级联一致); `PanelGlobalKey()` 混合层序 → 层序变化时整层面板缓存作废并按新层序重画。 - `QueryCursor` 与 `OnMouseMove` 的“是否在面板上”判断也改用 `TopPanelAtPoint`(与点击一致)。 ### D. 测试与脚本 - `uitest` 新增 `[AG]`:层预算(`RenderPendingLaneRebuilds(1)` 一帧只重画 1 个网格、剩余留在队列、 最终补齐且全部就绪)、**局部贴图**(把某层 `wideMin` 推到只覆盖视界左半 → 断言左侧被贴、 右侧保持哨兵色 = “未被绘制就不管”)、面板层序(上层面板 ✕ 命中上层、下层面板不受影响、 点击下层把它置顶、`TopPanelAtPoint` 随之改变、新开面板在最上层)。 - `uitest` 的 `[X]` 改为逐信号参数(含两个输入框的就地编辑:按下进入编辑、输入 175 回车写入 **该信号自己的条目**、999 被夹到 200、迭代次数写入、上下限夹取),并删除已不存在的滑块断言。 - 新增离屏探针 `.preflight/panperf`(平移帧耗时 + 按住键静止帧耗时)。 - **验证**:`make all` 零警告;`uitest` **388/388 通过**;`panperf` PASS; `.preflight/layercheck` 保持“居中图层 vs 整幅重绘逐像素 0 差异 / 平移对齐 ≤1px / 0 次视界覆盖”。 --- ## 本轮(2026-10-12,起源设备):弱峰增强改回**逐信号滑块** + 「迭代次数 N」上限可编辑 用户要求:把上一轮改成“逐信号两个输入框”的参数输入方式,改回**上一次提交之前**的输入方式 (滑块),但**每个信号各自一套滑块**;并且「迭代次数 N」滑块右侧的数值可以点击, 整行(滑块 + 数值)变成一个输入框来修改该滑块的**上限**。 ### 数据模型 - `Lane::WeakPeakItem` 新增 `int maxIterations = 10;`(**该信号迭代次数滑块的上限**,用户可改)。 - 上限参与 `PanelSlotKey`(决定圆钮位置 → 面板必须重画),但**不进 `SceneContentHash`** (上限不影响网格内画出的曲线,因此不该让波形重画)。 ### 面板输入方式(滑块的“上一次提交之前”形态,但逐信号) - `EdgePanelLayout` 新增四个**逐信号**矩形数组(`std::vector`,与 `rowRefs` 同序): `rowLevelTracks` / `rowLevelValues`(增强等级滑块 + 右侧数值)、 `rowIterTracks` / `rowIterValues`(迭代次数滑块 + 右侧数值)。 PEAK 面板的 `rowPoints`/`rowIterations`(文本输入框)**恒为空矩形**(MEAN/CENTER 仍用它们)。 - 行高:每个**被勾选**的信号 = 名称行(22)+ **两行滑块**(各 26)= 74;未勾选只占名称行。 - 列宽算式集中在两个文件内辅助函数里,`EdgePanelWidth` 与 `EdgePanelLayoutFor` 共用, 避免两处漂移:`WeakPeakLabelColumnWidth(uiSmall)`、`WeakPeakValueColumnWidth(monoSmall)` (后者取 `"200%"` 与 `"10000"` 的较宽者,为可改大的上限留位宽)。 轨道最小宽度用恢复的 `kWeakPeakTrackMinWidth = 120`。 - 恢复 `WeakPeakSliderHit()`(按轨道矩形上下各放宽 ≥8px 命中)与 `PlotDragMode::PEAK_LEVEL / PEAK_ITERATIONS`、`m_peakDragLane` + 新增 **`m_peakDragRef`** (每个信号一套参数,拖动时必须记住是**哪个信号**)。 - 删除 `PlotEditTarget::PEAK_LEVEL_PERCENT` / `PEAK_ITERATIONS` (等级只能拖滑块;迭代次数只能拖滑块),新增 **`PlotEditTarget::PEAK_ITERATIONS_MAX`**。 ### 「迭代次数 N」上限的就地编辑(用户明确要求) - **触发**:点击**迭代次数滑块右侧的数值文本**(`rowIterValues[i]`)。 - **输入框范围**:整条滑块行 —— 从轨道左端到数值文本右端 (`rowRect = {track.X, value.Y, value.X + value.Width - track.X, value.Height}`), 即“整个滑动条部分包括数字完整变为一个输入框”。左侧的「迭代次数 N」标签保留可见。 - **初始内容为空**:`BeginEdit(PEAK_ITERATIONS_MAX, lane, L"", rowRect)` 之后 再用 `m_edit.Begin(L"", false, EditFilter::BYTE, 0u)` 覆盖过滤器 (`BeginEdit` 默认 `NUMBER`,允许负号/小数点)。 - **灰色提示**:`PaintEditField()` 里当 `m_editTarget == PEAK_ITERATIONS_MAX && m_edit.Text().empty()` 时用 `UiTextAtClipped` 画当前上限(`m_peakEditCurrentMax`,进入编辑时记录、`EndEdit` 清零); 提示文本比插入符右移 `11 * scale`,避免插入符压在数字上。 该输入框在 `paintOverlays()` 里与 Y 轴上下限输入框一起绘制(在面板之上)。 - **提交**:回车 / 点击别处(沿用既有 `EndEdit` 路径)。规则: * `EditFilter::BYTE` = **只允许数字**(负号、小数点、字母根本无法键入,粘贴同样被清洗); * `parsedOk` 且 `1 ≤ value ≤ kWeakPeakIterationsMaxLimit(=1000)` → 生效为新上限; 否则(空内容 / `0` / 超出范围)→ **保持原上限不变**; * 生效后若 `iterations > maxIterations` → **自动替换为新上限**(并清 `m_weakPeakCurveCache`)。 - **滑块量程**:`1 … maxIterations`;比例 = `(iterations-1)/(maxIterations-1)`(`max≤1` 时取 0)。 - 「构建为新信号」按该信号自己的量程夹取迭代次数。 ### 验证(全部离屏,不占用鼠标/键盘/屏幕) - `uitest` `[X]` 重写为:逐信号滑块存在性/层序/列宽/面板内、**等级滑块拖动**(中间=100%、 右端=200%)、**迭代滑块拖动**(左端=1、右端=当前上限 10)、 **点击数值进入 `PEAK_ITERATIONS_MAX` 编辑**(空内容 + 提示值 10 + 输入框横跨整行、 空内容回车不改、`-`/字母被拒、输入 40 生效、右端变为 40、下调到 4 时当前值被夹到 4、 输入 0 被拒、点击别处也能提交),并保存编辑态截图 `X_peak_max_edit.png` 供目视核对。 - ⚠️ 测试坐标坑:`UiPointInRect` 右边界为**开区间**,按压“轨道最右端”的取整坐标可能落在 命中区之外 → 测试里按 `lround(X+W) - 1`;而**拖拽**可以超出(`UiSliderRatioFromX` 会夹到 1)。 - `make all` 零警告;`uitest` **409/409 通过**;`panperf` PASS; `layercheck` 保持“居中图层逐像素 0 差异 / 平移对齐 ≤1px / 0 次视界覆盖”。 --- ## 本轮(2026-10-12,起源设备):斜向自由移动视界退化 + 预览曲线抗锯齿 + 预览曲线闪烁 用户报告(原文要点):以 `VERY_BIG_DATA.CSV` 为例,全信号显示 + **时间轴方向选区放大**(主时间轴约 **2ms 一大格**)后, 用**鼠标中键/左键斜向拖拽**自由移动视界**仍然卡顿**,且"这种不规则的视界自由移动会导致性能问题和绘制波形质量下降、错误绘制"; 同时"实验性功能的预览曲线显示质量不如普通信号,似乎没有抗锯齿";"拖动弱峰增强滑块时预览曲线**消失一会儿又画出来**(跳帧闪烁)"。 ### A. 斜向拖拽的根因(为什么"纯水平"测不出来) `ShiftLaneRange(lane, dy)`(拖拽的纵向分量 → 网格幅值平移)每帧都会修改该网格的 `visual.yMin/yMax`,而: 1. `SceneGeometryHash()` 原本 = `SceneContentHash(false)`,**包含每个网格的 `yMin/yMax`** → 幅值一变,"场景几何变了", 于是**每一帧都判定宽基底不可用** → 退化到整幅重绘,并因拖拽置 `m_fastFrame = true` → **整幅 + 关抗锯齿**(质量下降 + 卡顿)。 2. `LaneLayerKey(laneIndex)` 的基也是**全局** `SceneContentHash(true, false)` → 幅值一变,**5 个网格的图层全部作废**并全部入队; 而 `RenderPendingLaneRebuilds(1)` **一帧只补 1 个** → 一直贴的是内容不一致的旧图层 = **错误绘制/断线**; 并且该调用点在 `BlitWideBuffer` **之后**,本帧补好的网格要到下一帧才显示("波形比鼠标慢半拍")。 ⚠️ 这解释了自动化盲区:**纯水平平移**不会改幅值,所以旧探针"看起来一切正常";只有"斜向/不规则"拖拽才暴露。 ### B. 修法(三个指纹各司其职) - `SceneContentHash(bool includeStyles, bool includeDerived, bool includeLaneRange = true)`: 新增 `includeLaneRange`;`SceneGeometryHash()` = `SceneContentHash(false, true, **false**)` → **幅值范围不再参与"能不能走宽基底快路径"的判定**(它只影响"画到哪里",不影响"几何结构")。 - 新增 **`LaneLayerHash(laneIndex)`**:单网格图层的内容指纹 = 位图高度 + 该网格 `yMin/yMax` + 该网格各信号的 `visible/color/opacity/lineWidth/mode`;`LaneLayerKey(laneIndex)` 的基由全局内容指纹改为它。 → 幅值变化**只让那一个网格**的图层失效(其余网格保持 Ready,零复制)。 - `Paint()` 的宽基底分支里,`RenderPendingLaneRebuilds(1)` 从 `BlitWideBuffer` **之后**移到 `RollWideBuffer()` **之后、贴图之前** → 本帧预算内补好的网格**本帧**就可见(消除"慢半拍/断线")。 ### C. 预览曲线抗锯齿(用户:"似乎没有过抗锯齿机制") - `PaintDerivedCurve` 原本 `if (m_fastFrame || spiky) SetSmoothingMode(None)`。 波形**基底**来自图层,**永远**是全质量抗锯齿;预览曲线却在任何交互期间变成无抗锯齿 → 叠加在一起就是"预览比普通信号粗糙"。 - 改为 **`if (spiky)`**:只有"陡峭段占比 > 1/8"的形状(方波/编码器)才放弃抗锯齿(那种形态下**观感几乎无差别**, 而实测代价极大:2448 顶点 26~37ms vs 无抗锯齿 3.5ms)。预览曲线只是叠加层、可见区间很小,抗锯齿代价可以承受。 - 交互期间**仍然**把顶点预算减半(`budget / 2`,即"低分辨率 → 松手恢复高分辨率"),只是不再关抗锯齿。 ### D. 预览曲线闪烁(拖参数滑块时消失一会儿) `AcquireNewestCurve(key, spanBegin)` 原本要求 **`entry->key == key`(参数完全相同)**。 拖滑块时参数每帧都在变 → 上一帧的曲线"键不同" → 找不到任何可用的旧曲线 → `CurveSpanFor` 返回 `nullptr` → 本帧**无曲线可画**(消失),直到后台按新参数算完并发布(间隔一会儿又出现)= 用户看到的闪烁。 修法:`AcquireNewestCurve(key, begin, end, spanBegin)` **只按 `key.signal` / `key.kind` 匹配**(参数无关), 并**优先挑「窗口覆盖所需区间 `[begin,end]`」的条目**(覆盖不到才退化为"最新的")。旧曲线只是参数略旧, 按采样下标映射位置仍然完全正确 → 视觉上平滑过渡而非闪烁。 ### E. 离屏验证(不占用鼠标/键盘/屏幕) - `uitest` 新增 `[AH]`(斜向拖拽 + 预览曲线连续性 + 抗锯齿): ① `LaneLayerKey` 逐网格失效(改 1 号网格幅值 → 1 号键变、2 号键**不变**); ② "同时平移 + 改幅值"的一帧 `m_wideLastValid == true`(走宽基底快路径)、未动的网格保持 Ready; ③ 参数连改 10 次:最差派生像素 21013(预热 22629),**空白帧 0 / 10**; ④ **隔离抗锯齿断言**:把 `PaintDerivedCurve` 画到两张 `gd::Bitmap` 上(`m_fastFrame = false` / `true`), 统计"既不是背景也不是纯线色的**混淆边缘像素**"→ 普通帧 8158/8420、快帧 8149/8404(都远大于 1/10) —— 这正是回归护栏:**旧实现快帧的混淆像素只有 2 个**(断言立刻失败)。 ⚠️ 写这类隔离测试的两个坑:GDI+ `Bitmap` **不保证初始为黑**(要 `Graphics::Clear`);测试曲线要用 **网格自己的值域**(`yMin..yMax`)且只画几个周期,否则曲线"每段都陡峭"会踩到那条无抗锯齿的逃生口,测出来的不是真问题。 - `panperf`(同一深放大区间,5 网格,每帧 60px 水平 / 斜向 60px+3px): * 水平平移 40 帧:中位 **6.17ms** / p90 7.13 / max 9.22; * **斜向拖拽 40 帧:中位 9.16ms / p90 10.67 / max 11.80,走宽基底快路径 40/40**(修复前该路径 0/40); * 与整幅重绘的像素差:斜向最差 79809,水平基线 77310~80919 —— **同一量级**,即只有那套已知的 ≤0.5px 整数像素残差, 不存在"质量下降"或"错误绘制"。 - `layercheck`:`clobbers=0/24`、`pan frames wrong=0/4`、居中图层 vs 整幅重绘逐像素 0 差异(未回归)。 - `make clean && make all` 零警告;`uitest` **420/420 通过**。 ### F. 教训(写进 §11.2 硬约束) 1. **网格幅值范围(`yMin/yMax`)不得进入"几何/内容"指纹**(那会让"平移的纵向分量"每帧毁掉快路径); 它只属于**该网格自己的图层指纹**。 2. **图层指纹必须逐网格**:任何"一个网格的变化使全部网格作废"的全局指纹都会把"一帧只补 1 个"的预算变成永久不一致。 3. **待重画网格要在贴图之前补**,否则用户看到的是"比鼠标慢半拍"的旧像素。 4. **异步发布的派生曲线必须有"参数无关"的兜底**(按信号 + 种类取最新),否则拖参数滑块必然闪烁。 5. **交互优化只能降分辨率(顶点数),不能关抗锯齿**:基底图层永远是全质量抗锯齿,叠加层一旦关了就会"看着更粗糙"。 --- ## 本轮(2026-10-12,起源设备):上下/斜向平移卡顿(纵向预渲染)+ 深放大拖拽光标跑飞 用户报告:以 `VERY_BIG_DATA.CSV` 为例,**Y 轴自适应 + 选区放大**到主时间轴约 1~2ms 一大格后,中键拖拽移动视界时「左右很流畅,但上下或斜向会卡顿」,并猜测「上下移动时没有像左右那样的预渲染缓存」。另外「小缩放区间拖拽光标跑飞」的旧缺陷复现。 ### A. 先验证猜测(量化,而不是凭感觉) `.preflight/panperf` 新增:**纯竖直拖拽**(只改被拖网格幅值、视界不动)、逐帧统计 `BackgroundLayerRenderCount()` 增量,以及「5 网格全开 4 类悬浮面板(20 个)」后的水平/竖直对比。结果: | 场景 | 水平拖拽 | 竖直拖拽 | 每次竖直帧的图层重画次数 | | --- | --- | --- | --- | | 无面板 | 6.29ms/帧 | 5.26ms/帧 | **1 次/帧**(水平:5 个横向补条/帧) | | 20 个悬浮面板 | 37.7ms/帧 | **166.6ms/帧** | 同上 | 结论:猜测**部分成立**——竖直方向确实没有预渲染(每帧整块重画该网格图层),但**主因**是另一处:浮动面板的**像素图层全局键**里含 `visual.yMin/yMax`,平移的纵向分量每帧小改幅值 → 整层面板缓存失效 → 20 个面板每帧全部重画(≈130ms/帧)。水平平移不改幅值,所以只有竖直/斜向才触发 —— 这正好解释了用户观察到的「不对称」。 ### B. 图层改为**二维预渲染**(横向 ±半屏 / 纵向 ±半个网格高) - `LaneLayer` 新增 `marginY`(可见网格上方的余量行数)、`anchorYMax`(渲染时的 `yMax`)、 `pxPerValue`(像素/数值单位);位图高度 = `ceil(可见网格高) + 2*marginY`。 - 渲染时把该网格幅值范围按**像素行**精确扩展(上扩 `marginY/pxPerValue`,下扩 `(bitmapHeight - marginY - laneHeight)/pxPerValue`)。⚠️ 必须按行数反推数值扩展量: 只有这样「扩展跨度 / 画布行数」才严格等于前台的 px/数值,可见网格在画布中的映射才与整幅渲染**逐像素同源**。 早先按「上下各扩同样的数值量」时存在 <1px 的纵向缩放误差,水平平移的像素残差实测从 77k 涨到 105k,改对后回到 77k。 - 图层里的**幅值刻度必须以前台可见网格的跨度/高度为基准**(`m_wideTickSpan` / `m_wideTickHeight`):画布更高、范围更宽,若按画布值取 `NiceStep`,网格线密度就与前台不一致(贴图后拖动网格线跳变)。 步长一致 ⇒ 刻度落在同一批绝对值格点上 ⇒ 纵向平移就是纯像素平移。 - 贴图:`srcY = round(top - lane.Y) + marginY + round((anchorYMax - 当前 yMax) * pxPerValue)`;超出画布上下端时只贴**有像素的那一段**。`RollWideBuffer` 里只有 `|偏移|` 超出余量时才排队重画(重画即重新居中)。 - 图层指纹:`LaneLayerHash` 只计幅值**跨度**(`yMax - yMin`),**不计绝对范围**(绝对范围是贴图偏移的来源)。 - ⚠️ **网格分界线必须移出图层**:它锚定在网格的**屏幕位置**,而图层会被纵向偏移贴图;若烘进图层,竖直拖动时它会跟着波形跑。现在整幅重绘由 `PaintLane` 画,宽基底快路径由 `PaintLaneDividers()` 补画一次。 ### C. 面板像素图层不再被幅值范围击穿 - `PanelGlobalKey()` 去掉 `visual.yMin/yMax`:面板内容(标题、条目、滑块、参数、边沿统计)都与幅值区间无关。 - 代价:面板底是不透明度 95.7% 的圆角矩形,缓存像素里"它下面的场景"可能旧一帧 → 透出 **4.3%**,色差**有上界**(≤ 0.043×255 ≈ 11 级),与既有的「旧一帧」取舍一致,[AF] 的容差 96 覆盖得住。 ### D. 深放大下拖拽光标「跑飞」 - 历史:`388d3b6`/`67f659a` 里「视图变化后夹光标」被 `eaf5b9c` 刻意删除(改为「光标可滞留在视界外 + 贴边读数 + 方向三角」)。但深放大下一屏平移就是几十倍可视跨度,光标瞬间被甩到视界外很远 —— 用户看到的就是「拖拽(视界)时光标跑飞」。 - 修法(区分**连续手势**与**离散视图变化**): * 新增 `ClampCursorsAfterPan()`,在**平移**路径调用:PAN 拖拽(中键/左键)、Shift+滚轮、方向键(`PanTimeByFraction`)。 * ⚠️ **离散**视图变化(选区放大 / 适应视图 / 按网格时间轴展开)**仍然不移动光标** —— 用户可以先量好再自由探索, 贴边读数可读、可拖回来。不要在这几处加夹取。 * 抓取**被钉在边缘**的光标读数时不带抓取偏移(`CursorOffViewSide` 为真 → `m_cursorGrabOffset = 0`): 否则首次移动会把目标送到视界外再被夹回边缘(表现为"一拖就跳很远 / 光标钉在边缘不动")。 ### E. 验证(全部离屏,不占用鼠标/键盘/屏幕) - `uitest` **432/432**:`[AH]` 更新为"幅值**平移**不再让图层失效(只改贴图偏移)、幅值**跨度**变化仍然失效",并新增「纯竖直拖拽 8 帧 **0 次图层重画**」;新增 `[AI]`:贴边读数抓取后光标落在鼠标处(误差 **0.0px**)、中键平移 1.5 屏期间 **0 帧出界**且光标确实跟手。 - `panperf`(VERY_BIG_DATA、5 网格、≈1~2ms 一大格):竖直拖拽中位 **3.8~5.0ms**、40 帧仅 **1** 次图层重画;水平 5.7~6.5ms(与修复前持平);对角 11~13ms(略增:补条要按 2 倍画布高渲染);整幅质量对比残差与修复前同量级(水平 77k~81k ↔ 修复后 77k~81k;对角仅被拖动的那一个网格增加,属同一类 ≤0.5px 整数像素残差)。 - 20 个悬浮面板时:竖直 **166.6 → 24.8ms/帧**,水平 37.7 → 38.8ms(持平)——"上下比左右卡"的不对称消失。 - `layercheck`:`clobbers=0/24`、`pan frames wrong=0/4`、居中图层 vs 整幅重绘逐像素 0 差异;新增探针 `.preflight/cursorcheck`(真文件 + 真缩放)**30/30**(单/双光标拖拽、贴边读数抓取、中键/键盘/滚轮平移)。 - `make clean && make all` 零警告。 --- ## 本轮(2026-10-12,起源设备):纵向滚动补齐(消除绘制异常)+「弱峰调整」(-200%–+200%) 用户报告:① 上下拖动视界确实流畅了,但**绘制出现错误**——网格内的信号没有按预期移动,并出现抽搐帧;**尤其把中键按在最上方网格上、一路拖到窗口最下方(鼠标离开网格很远)**时最明显;左右拖动同样出现。② 把「弱峰增强」升级为「弱峰调整」:面板的「增强等级」改为「调整幅度」,量程 -200% – +200%,0 以上与原算法一致,0 以下为**削弱**(把弱峰压向相邻波谷、甚至抹除,左右平滑过渡;波谷反之)。 ### A. 绘制异常/抽搐的根因(上一轮纵向余量只补了一半) 上一轮给图层加了「上下各半个网格高」的纵向余量,并把幅值平移改成**贴图纵向偏移**,但"偏移超出余量"时仍然走**排队整块重画**(`QueueLaneRebuild` → `RenderPendingLaneRebuilds(1)`,一帧只补一个网格)。于是: 1. 重画要到(最多)下一帧才生效,而 `BlitWideBuffer` 本帧就按新偏移取源行 → 源行超出画布时 **只能贴出有像素的那一段,其余行留在底色上 = 网格内出现空白条带**("绘制错误")。 2. 重画后的锚点与"贴图偏移"不同源 → 波形在"偏移画法"与"重画后的居中画法"之间跳 = "没按预期移动"。 3. 鼠标拖到网格外很远时,每帧的幅值位移都远超余量 → **每帧都触发整块重画**(画布比可见网格高 3 倍,代价不小)= 抽搐。 ### B. 修法:与横向**完全同构**的纵向滚动 - `RenderLaneLayerRegion` 增加 `stripY/stripH`(纵向条带),因此可以只补画"新暴露的一整条"(整宽 × n 行)。 - `RollWideBuffer` 里新增纵向滚动:`BitBlt` 整块上/下移 `roll` 行 → 补画暴露的那一条 → `layer.anchorYMax -= roll / pxPerValue`(与横向 `layer.wideMin += shift / scale` 同一套记账)。 ⚠️ **必须在横向补条之前**:横向补条会用"当前 `yMax`"重置图层锚点,顺序反了细条会带上错的纵向偏移。 ⚠️ **滚动量量化**(`kLayerRollQuantum = 16` 像素):不足量子的偏移**不滚**,交给贴图的 `srcY` 偏移处理。 这样画布映射在拖动过程中保持稳定 → 折线缓存(键含幅值映射)命中;未量化时"每帧滚 1~30 行" 会让折线每帧重算(实测滚动 3~4ms + 整帧 10~14ms)。余量(上下各半个网格高)足以吸收 16px 的残差, 所以"不滚只偏移"绝不会贴出空白。 ### C. 「弱峰调整」(原「弱峰增强」) - 术语统一:`ComputeWeakPeakAdjust`、`WeakPeakItem.amountPercent`(-200–200,默认 150)、 面板标题「弱峰调整」、标签「调整幅度 %」、光标胶囊尾缀「弱峰调整」、 构建信号名 `<原信号> (弱峰调整 X% M次迭代)`(X 可为负)。 - 滑块:量程 -200% – +200%,**0 在正中间**(`ratio = (amount/200 + 1) / 2`);数值列宽度改为量 `"-200%"`。 - 目标值(`amount` = 幅度/100,识别规则**与旧实现完全相同**): * `0 ≤ a ≤ 1`:当前值 → 同类极值中**较温和**的一侧(波峰取较低的波峰、波谷取较高的波谷); * `1 < a ≤ 2`:该侧 → 另一侧(**与旧实现逐位一致**。⚠️ 波峰 `lower + (a-1)*(upper-lower)`、 波谷 `upper - (a-1)*(upper-lower)` 方向相反,符号不能写成一样——本轮先写错,被既有测试立刻抓住); * `-1 ≤ a < 0`:当前值 → 相邻**异类**极值中**较近**的一个(波峰取较高的波谷、波谷取较低的波峰); * `-2 ≤ a < -1`:较近的异类极值 → 较远的那个。即 -100% 拉到较近的波谷、-200% 拉到较远的波谷, 弱峰被压平甚至抹除,左右波形平滑过渡(波谷反向同理)。 * ⚠️ 负幅度**不得反向增强**:目标夹在"不越过当前值"的一侧(噪声使异类极值落在错侧时)。 - 平滑偏移(相邻极值之间的 smoothstep 过渡)**逐字未改**,因此负幅度下"一个极值被压低、相邻被抬高"时, 两者之间的**中间采样点可能越过中值线**——这是平滑过渡的正常结果, **不要把"逐点不得越过中值"写成断言**(只在极值点上断言"未反向增强")。 ### D. 验证(全部离屏,不占用鼠标/键盘/屏幕) - `uitest` **443/443**:`[X]` 正幅度 0/50/100/150/200% → 峰 550/555/560/580/600(**旧值逐位保持**)、 谷 450/445/440/420/400;新增负幅度 -50/-100/-200% → 峰 **500/450/400**、谷 **500/550/560**; 超范围(+7 / -7)夹取;"未反向增强"(极值点)与"健康峰不动"; 滑块:中间 = 0%、右端(拖出)= +200%、左端(拖出)= -200%;构建信号名含「弱峰调整」; `[AH]` 纯竖直拖拽 16 帧仅 **2** 次图层重画(量化滚动)。 - `panperf`:拖出网格 900px(鼠标远在窗口外)+ 对角平移 31 帧,**被拖网格的空白行数与整幅重绘逐帧一致** (64 vs 64 → 无空白条带)、帧时 10~13ms(滚动 2~4ms)、末帧与整幅重绘的差异与水平基线同量级 (best integer shift (-1,0),即已知的 ≤0.5px 整数像素残差)。 - `layercheck`:`clobbers=0/24`、`pan frames wrong=0/4`、居中图层 vs 整幅重绘逐像素 0 差异; `cursorcheck` 30/30;`make clean && make all` 零警告。 --- ## 本轮(2026-10-12,起源设备):图层条带 GDI 裁剪(绘制错误真因)+「弱峰调整」定义与默认值 用户报告:① 上下拖动变流畅了,但**绘制错误仍在**——对某个网格中键上拖半个网格的纵向视界后,按住中键不放再左右/上下/斜向拖拽**都会**出现绘制错误;② 调整「弱峰调整」的判定定义与默认值。 ### A. 绘制错误真因:图层条带渲染时 GDI 网格线"越界作画"并烘进图层 上一轮把图层条带渲染从"只有横向条"扩展到"横向条 + **纵向条**"(纵向滚动补画)。但 `PaintLane` 里 **GDI**(网格线 / 分界线)用的裁剪矩形是 `RectF(m_area.X, clipTop, m_area.Width, clipBottom - clipTop)` —— 它只把 x 限制在条带内,**y 仍是整个网格可见范围**。于是纵向条带(全宽 × 窄 y)渲染时: 1. GDI 网格线被画到**条带之外**(该处像素由滚动而来、本来是正确的); 2. 那些行原本是"波形盖在网格线之上",现在变成"网格线盖在波形之上" → 与正确渲染不一致; 3. 更糟的是这些错误像素**就在图层位图里**,会被后续每次平移带着一起移动 → 用户看到"拖到哪儿都有绘制错误"(错误行随内容漂移,无法通过重绘恢复)。 修法:把"本次允许改动的区域"算成 `laneClip = IntersectRect(m_area, 网格可见矩形)`,**同时**用于 `Graphics::SetClip` 与 GDI 的 `gridClip`。(横向补条本来就没问题,这也是为什么此前只有纵向/斜向才暴露。) ### B. 复现与验证(`.preflight/dragcheck`) 完全照用户手势:注入**真实中键消息**(`WM_MBUTTONDOWN` + `WM_MOUSEMOVE`),先把最上方网格向上拖半个网格高(触发纵向滚动),再保持按住拖左右/上下/斜向、最后拖出窗口。 判定方式(去掉亚像素噪声):把图层画布的**有效条带**(`marginY + vOffset` 起、网格高行)与"用同一 `wideMin` 新渲染的画布"逐像素比较 —— 滚动是纯整数平移,二者必须逐像素相同。(用 `Bitmap::FromHBITMAP` + `LockBits` 直接读像素,`GetPixel` 太慢。) - 修复前:被拖网格出现**整行不一致**(2410 px ≈ 2 行 × 全宽),其他网格只有 ~20 px; 并且**错误行固定在 canvas 行 138**(即某条横向网格线的行号)——直接指向 GDI 网格线。 - 修复后:最差 ≤171 px(仅补条边界处的抗锯齿边缘,占 0.09%),0 处绘制错误。 ### C. 「弱峰调整」的定义、默认幅度与符号显示 - **「是否偏弱」的新定义**(正/负幅度一致,用户明确要求): * 波峰 k:必须**同时**低于 左侧「相邻第一个波峰 k-2 与相邻第一个波谷 k-1 的均值」**且** 右侧「相邻第一个波谷 k+1 与相邻第一个波峰 k+2 的均值」; * 波谷 k:必须**同时**高于上述两个均值。 ⚠️ 与旧实现(只与左右两个**同类**极值比较)不同:**整段均匀偏弱的纹波不再被判为弱峰**(它两侧没有"更健康"的组合可比),因此测试数据必须重设计(新的合成波形为分段线性、谷恒 500、弱峰 520、健康峰 620/640)。 - **默认幅度改为 0%**(面板打开即不改动波形;`WeakPeakItem`/构建请求/曲线缓存项三处默认值)。 - **滑块右侧数值始终显示符号**:`+150%` / `-150%` / `0%`(0 无符号);数值列按 `"+200%"` 取宽,避免文字跳动。离屏验证:`+150%` 与 `-150%` 的墨迹宽度一致(40 / 38 px),且都比 `0%`(16 px)宽 ≥8px。 ### D. 验证(全部离屏,不占用鼠标/键盘/屏幕) - `uitest` **441/441**:`[X]` 正幅度 0/50/100/150/200% → 峰 520/570/620/630/640、弱谷 580/540/500/500/500;负幅度 -50/-100/-200% → 峰 510/500/500、谷 590/600/620;超范围夹取、"未反向增强"(极值点)、健康段逐位不变、30% 幅度下 N=1/2/3/10 → 550/571/571/571(收敛)、默认 0%、滑块中间 = 0%、两端 = ±200%、数值带符号。 - `[Y]` 端到端:合成 ripple(谷恒 500、一个弱峰 520)弱峰区间 **20 → 70**(健康 120)。 - `dragcheck` PASS(0 绘制错误);`panperf` 竖直中位 5.1ms、水平 7.1ms、面板全开竖直 25.0ms / 水平 41.3ms;`layercheck` `clobbers=0/24`、`pan frames wrong=0/4`;`cursorcheck` 30/30;`make clean && make all` 零警告。 --- ## 本轮(2026-10-12,起源设备):「弱峰调整」三组识别规则 + 滑动条 ±400% 梯度驱动 用户要求:滑动条量程扩到 **±400%**,并按"梯度"同时驱动**三组识别规则不同的点**;每组的 ±200% 映射里,±200% 的落点由"较极端的那一个邻居"改成"两个邻居的均值"。 ### A. 三组识别规则(以波峰 k 为例;`L`/`l` = 左相邻波峰/波谷,`r`/`R` = 右相邻波谷/波峰) 令 `leftMean = (L+l)/2`、`rightMean = (r+R)/2`: | 组 | 规则 | 一句话 | | --- | --- | --- | | 第一类 | `v < leftMean` **且** `v < rightMean` | 两侧的"相邻峰+相邻谷"均值都要低于 | | 第二类 | `v < leftMean` **或** `v < rightMean` | 任一侧满足即可(从"且"放宽为"或")| | 第三类 | `v < L` **且** `v < R` | 只与左右**同类**极值比较(最宽松)| 波谷把"低于"换成"高于"、min/max 互换。⚠️ 三组**不是包含关系**:`v < min(L,R)` 并不蕴含 `v < max(leftMean,rightMean)`(因为 `leftMean < L`、`rightMean < R`),所以确实存在"只满足第三类"的点。 ### B. 梯度驱动(滑动条 |a| ∈ [0, 400%]) | 滑动条 | 第一类 | 第二类 | 第三类 | | --- | --- | --- | --- | | 0 – 100% | 0 – 100% | — | — | | 100 – 200% | 100 – 200% | 0 – 100% | — | | 200 – 300% | 200%(上限) | 100 – 200% | 0 – 100% | | 300 – 400% | 200%(上限) | 200%(上限) | 100 – 200% | 实现(`a` = 百分比/100):`first = sign*min(|a|,2)`、`second = sign*clamp(|a|-1,0,2)`、`third = sign*clamp(|a|-2,0,2)`。某个极值同时满足多组时取**幅度绝对值最大**的一组(= 最严格的那组)。 ### C. 每一组自身的目标值映射(±200%) - `0 – +100%`:当前值 → 左右**同类**极值中**较温和**的一侧(波峰取较低者、波谷取较高者); - `+100 – +200%`:该侧 → 左右同类极值的**均值**(原先取"较大的那个"); - `0 – -100%`:当前值 → 相邻**异类**极值中**较近**的一个(波峰取较高的波谷、波谷取较低的波峰); - `-100 – -200%`:该侧 → 相邻异类极值的**均值**(原先取"最小的那个")。 ⚠️ 削弱的"不得反向增强"夹取保持不变;⚠️ 平滑偏移(相邻极值之间的 smoothstep)**逐字未改**,因此负幅度下相邻极值一压一抬时,两者之间的**中间采样点可能越过中值线**(正常现象,只能在**极值点**上断言"未反向增强")。 ### D. 界面 滑动条量程 → **-400% – +400%**(0 在正中间,`kWeakPeakSliderScale = 4.0`);数值列按 `"+400%"` 取宽;右侧数值**始终带符号**(`+400%` / `-400%` / `0%`)。 ### E. 验证(全部离屏,不占用鼠标/键盘/屏幕) - `uitest` **463/463**: * 第一类点(弱峰 520、相邻峰 620/640):0/50/100/150/200% → 520/570/620/**625/630**; 弱谷 580(相邻谷 500/500、相邻峰 620/600):580/540/500/500/500,负值 590/600/**605/610**。 * **只满足第二类**的峰 130(相邻峰 200/150、相邻谷 100/90 → 侧均值 150/120): 100% 不动;150/200/250/300/400% → 140/150/162.5/175/175; 负值 -100% 不动;-150/-200/-250/-300% → 115/100/97.5/95。 * **只满足第三类**的峰 195(两个 200 峰之间,侧均值为 150):200% 不动;250/300/400% → 197.5/200/200。 * 第一类点在 200% 已达自身上限(400% 不再变化);±400% 夹取;默认 0%;滑块中间 = 0%、两端 = ±400%、 轨道 3/4 处 ≈ +200%(第一类上限)。 - `[Y]` 端到端:合成 ripple(谷恒 500、一个弱峰 520)在 50% 幅度下弱峰区间 20 → 70(健康 120)。 - `panperf` PASS、`layercheck` `clobbers=0/24`+`pan frames wrong=0/4`、`cursorcheck` 30/30、 `dragcheck` 0 绘制错误;`make clean && make all` 零警告。 --- ## 本轮(2026-10-12,起源设备):长距离中键拖拽的绘制错误/抽搐("距离"不再退化成排队重画) 用户报告(并附 `TestData/PROBLEM_IMG/*.png`):按住中键把视界**纵向移动好几个网格高**、不松开再挪回来(或继续上下移动)时,出现**大量绘制错误与抽搐**;要求"验证时不要松开中键截图"。 ### A. 根因(`RollWideBuffer` 的"距离太远 → 排队"分支) 上一轮的图层余量只覆盖「横向 ±半屏、纵向 ±半个网格高」,超出余量时旧代码只做: QueueLaneRebuild(laneIndex); // 本帧不画 continue; 于是只要"位移超过余量": 1. 该帧 `BlitWideBuffer` 的 `srcY = marginY + vOffset` 已经落在画布之外 → 只能贴出/贴不到像素, 网格里出现**空白条带**(用户截图里的"白色块"); 2. 下一帧位移依旧超限 → **每帧都重新入队**,而 `RenderPendingLaneRebuilds(1)` **一帧只补 1 个网格**, 5 个网格永远补不完 → **持续抽搐**; 3. 纵向余量只有 ±`marginY`(半个网格高 ≈ 80px),而中键纵向拖拽每帧很容易移动 20~150px, **几乎必现**;横向同理(余量 ±半屏,快速拖拽超过它)。 ### B. 修法:距离类问题一律本帧就地解决 - **只有"内容真的变了"(`contentStale`:外观 / 网格几何 / 幅值跨度 / 可见性…)才排队** —— 那时画面 本身仍是"旧内容",不缺口,可以让后续帧慢慢补。 - 距离类问题本帧处理: * `|shift| < bitmapWidth` 且 `|vOffset| < 画布高` → **滚动**:`BitBlt` 整块平移 + 只补画暴露的细条 (横向条 / 纵向条),并同步锚点记账(`wideMin` / `anchorYMax`); * 任一超出画布(内容整体滚出)→ **本帧立刻整块重画并重新居中** (`RenderLaneLayerRegion(i, layer, 0, bitmapWidth, 0, 0, LayerDefaultWideMin())`,实测 0.4ms/网格)。 ### C. 探针(`.preflight/dragcheck`,按用户要求"全程按住中键") 真文件中键消息注入,**不松开**;每一步都做三类检查: 1. **图层条带 vs 同锚点新渲染**:逐像素(滚动是纯整数平移,必须相同); 2. **覆盖率**:`(ViewMin - layer.wideMin) * scale == marginX`(±1px)且 `marginY + vOffset` 起的**一整个网格高**都落在画布内 —— 只要有一条不成立, `BlitWideBuffer` 就会留下空白条带; 3. **画面结构**:网格内"无墨迹行数"与"墨迹占比"对比**整幅重绘参考帧** (空白条带 / 内容错位探测器;不松开中键时截取)。 场景:① 上拖半个网格高;② 按住拖左右/上下/斜向并拖出窗口;③(用户复现)**纵向拖 4 个网格高再拖回来**、 再小幅上下摆动;④ **快速大位移对角拖拽**(每帧 150×110px,超过纵向余量)。 **A/B 验证探针有效性**:把纵向滚动临时关掉(`kLayerRollQuantum = 100000`)→ 立刻报 `worst layer band diff 76221`、`drawing errors 11`;恢复后 **0 错误**。 ### D. 两点说明(避免误判) - `dragcheck` 的"画面像素 diff"会稳定出现 ~2.5 万像素(一个网格的 ~11%)差异:这是**图层与整幅重绘的 列相位差**(图层画布 2449px 按 1px 列聚合,整幅重绘 1224px 列,相位差 ≤0.5px),对**稠密脉冲信号** (256kHz 编码器)会放大成大片边缘差异;这是长期存在、由"结算帧"收敛的现象,**不是**本轮缺陷。 因此画面检查采用结构化指标(空白行数 / 墨迹占比)而不是像素 diff。 - 用户截图里"整条网格被信号色填满"是**正确**渲染:把幅值窗口平移到与稠密信号相交的区间时, 每列的 min/max 包络本来就覆盖整个网格(示波器上同样如此)。 ### E. 验证(全部离屏,不占用鼠标/键盘/屏幕) - `dragcheck` PASS(0 绘制错误;长拖拽 80 帧 + 快速对角 24 帧,最差帧 ~25ms); - `uitest` **463/463**;`panperf` PASS(竖直中位 4.4ms、水平 7-10ms); `layercheck` `clobbers=0/24` + `pan frames wrong=0/4`;`cursorcheck` 30/30; - `make clean && make all` 零警告,`Build/Release/Target.exe` 已更新。