用户报告:Y 轴自适应 + 选区放大到约 1~2ms 一大格后,中键拖拽移动视界"左右很流畅,上下或
斜向会卡顿",并猜测"上下移动时没有像左右那样的预渲染缓存";另有"小缩放区间拖拽光标跑飞"。
一、先验证猜测(离屏量化,不凭感觉)
panperf 新增:纯竖直拖拽(只改被拖网格幅值、视界不动)+ 逐帧统计图层重画次数 + 5 网格全开
4 类悬浮面板(20 个)后的水平/竖直对比。结果:
* 无面板:水平 6.29ms/帧,竖直 5.26ms/帧,但竖直**每帧都整块重画那个网格的图层**(40/40)。
* 20 个面板:水平 37.7ms/帧,竖直 **166.6ms/帧** —— 这就是用户说的"上下卡顿"。
结论:猜测部分成立(纵向确实没有预渲染),但主因是另一处:浮动面板像素图层的**全局键**里含
visual.yMin/yMax,平移的纵向分量每帧小改幅值 → 整层 20 个面板每帧全部重画。水平平移不改幅值,
所以只有竖直/斜向才触发 —— 正好解释"左右流畅、上下卡顿"的不对称。
二、图层改为二维预渲染(横向 ±半屏 / 纵向 ±半个网格高)
- LaneLayer 增加 marginY / anchorYMax / pxPerValue;位图高度 = ceil(可见网格高) + 2*marginY。
- 幅值范围按**像素行**精确扩展(上扩 marginY/pxPerValue,下扩 (bitmapHeight - marginY - laneHeight)
/pxPerValue):只有这样"扩展跨度 / 画布行数"才严格等于前台的 px/数值,可见网格在画布里的映射
才与整幅渲染逐像素同源(按固定数值量扩展会留下 <1px 的纵向缩放误差,水平残差会从 77k 涨到 105k)。
- 图层里的幅值刻度改以**前台可见网格**的跨度/高度取 NiceStep(m_wideTickSpan / m_wideTickHeight):
画布更高更宽,若按画布值取步长,网格线密度与前台不一致(贴图后拖动时网格线跳变)。
- 贴图:srcY = round(top - lane.Y) + marginY + round((anchorYMax - 当前 yMax) * pxPerValue);
超出画布上下端时只贴有像素的那一段;偏移超出余量才排队重画(重画即重新居中)。
- LaneLayerHash 只计幅值**跨度**、不计绝对范围(绝对范围是贴图偏移的来源)。
- 网格分界线移出图层(m_renderWide 时跳过,改由 PaintLaneDividers() 在宽基底路径补画一次):
它锚定在网格的屏幕位置,纵向偏移贴图会把它带着一起跑。
三、面板像素图层不再被幅值范围击穿
PanelGlobalKey() 去掉 visual.yMin/yMax(面板内容与幅值区间无关)。代价仅是 95.7% 不透明底背后
那 4.3% 透出的底层像素可能旧一帧(色差有界 ≤ 0.043*255 ≈ 11 级),与既有的"旧一帧"取舍一致。
四、深放大下拖拽光标跑飞
历史:388d3b6/67f659a 的"视图变化后夹光标"被 eaf5b9c 刻意删除(改为光标可滞留在视界外 + 贴边读数
+ 方向三角);但深放大下一屏平移就是几十倍可视跨度,光标瞬间被甩到视界外很远 = 用户看到的"跑飞"。
修法(区分连续手势与离散视图变化):
- 新增 ClampCursorsAfterPan(),在**平移**路径调用:PAN 拖拽(中键/左键)、Shift+滚轮、方向键。
- 离散视图变化(选区放大 / 适应视图 / 按网格时间轴展开)仍然不移动光标(先量好再自由探索,
贴边读数可读、可拖回来)——不要把夹取加回这几处。
- 抓取被钉在边缘的光标读数时不再沿用抓取偏移(CursorOffViewSide → m_cursorGrabOffset = 0),
否则首次移动会把目标送到视界外再被夹回边缘("一拖就跳很远 / 光标钉在边缘不动")。
五、验证(全部离屏,不占用鼠标/键盘/屏幕)
- 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 次图层重画
(修复前 40/40);水平 5.7~6.5ms(与修复前 6.29ms 持平);20 个面板时竖直 166.6 → 24.8ms/帧,
水平 37.7 → 38.8ms/帧(不对称消失)。整幅质量残差与修复前同量级(水平 77k~81k ↔ 77k~81k)。
- layercheck:clobbers=0/24、pan frames wrong=0/4、居中图层 vs 整幅重绘逐像素 0 差异。
- 新增探针 .preflight/cursorcheck(真文件 + 真缩放)30/30。
- make clean && make all 零警告。
267 KiB
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 工作流要求
- 接手前先探索工作区/提交历史/代码建立认知(本文件应作为第一参考)。
- 完成需求后编译验证(
make release至少成功),必要时用TestData/数据功能验证。 - 验证与探索时尽量不占用用户的鼠标/屏幕/键盘(后台构建为主)。
- 本地仓库提交完成后,不要在未经指示的情况下继续探索或优化,等待用户下一步指令。
- 每次完成任务后更新本文件(需求、修复、约定变更)。
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→paintDerivedCapsulelambda):- 绘制时间光标处的派生曲线取值胶囊时,把请求窗口取成「可见区间 ∪ 光标采样下标」的并集;
- 当光标不在当前视界内(平移视界会把光标甩到视界外)时,这个并集会变成
[光标下标, 视界右端]或[视界左端, 光标下标]—— 上千万采样的巨大窗口; - 该窗口超过
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 帧的秒级耗时,正常)。
- 原始波形的「逐像素列包络带」用 GDI+
- 测量工具:
.preflight/perf/perf.exe <csv> [mode](离屏,不弹窗)。mode=0跑各交互场景耗时;mode=1导出确定性渲染 PNG 以便逐像素比对(用于验证"显示效果不变")。该目录已由.gitignore忽略,不会入库。
9.5 性能:启用带预览曲线的实验性功能后,交互严重卡顿(核心修复,含"为什么只有某些缩放等级卡"的解答)
-
症状(以
VERY_BIG_DATA.CSV,5 信号全部显示为例):- 「弱波增强」滑块拖动时在任何缩放等级下都卡顿;越缩得小(可见时间跨度越大)越卡 —— 时间跨度到 2.5s 时单帧 ≈89ms,完全放大(整段 128.8s)时单帧 ≈2.3 秒。
- 网格「选区放大」到某些跨度后,平移视界 / 改样式也卡。
-
根因(
Components/PlotView/Src/PlotView.cpp,CurveSpanFor+ 缓存策略 +Utilities/Src/DataModel.cpp的ComputeWeakWaveBoost):- 派生(预览)曲线按「可见采样窗口 ± 保护带」计算,代价正比于窗口内的采样数。实测:弱波增强整段(2576 万采样)单条 ≈368ms,均值滤波整段单条 ≈57ms。5 个网格就是 ≈1.8s。
- 旧代码有一条「超过 4M 采样的窗口不进缓存」的规则(为省内存),于是大跨度下每帧都重算整段曲线 —— 包括静止时的鼠标悬停重绘。这就是"某些缩放等级特别卡"的原因:
- 完全放大:窗口 = 整条信号(千万级)→ 每帧重算 → 秒级卡顿;
- 很小的时间跨度:窗口只有几千采样 → 计算只要微秒级 → 毫不卡顿;
- 中间跨度:窗口在十几万~几百万采样之间 → 单帧几十毫秒 → 卡,且保护带(≤65536)相对窗口太短,平移很容易越出缓存覆盖 → 频繁重算。
- 拖动「弱波增强」滑块每帧都改等级 → 缓存键变化 → 每帧都命中"未缓存"→ 每帧重算(叠加上述放大效应)。
-
修复(三管齐下,显示效果逐像素不变):
- 大窗口也进缓存:
kCurveSpanCacheMaxSamples提高到 64M(相当于不再按采样数排除),并新增总字节预算kCurveSpanCacheMaxBytes(≈768MB,最旧优先淘汰)兜底内存。这样同一参数下的平移/缩放/悬停都能复用,静止重绘降到 0.25ms。 - 交互期间「延迟重算」(
CurveSpanFor新增allowInteractiveDefer):拖动滑块/移动视界/缩放时,若所需窗口超过kDerivedInteractiveComputeBudget(131072 采样),本帧不重算,改复用上一次算好的曲线(曲线下标→时间的映射仍是当前视图,因此位置准确),交互结束后再按最终参数精确重算。窗口不大时(常见工作缩放)照常同步重算,预览曲线实时跟随控件。- 用途区分:预览绘制(
PaintMeanFilterCurve/PaintWeakBoostCurve/光标取值胶囊)传allowInteractiveDefer = true;需要精确取值的统计(中心轴边沿计数EdgeCenterCountsFor、MeanFilterValueAt/WeakBoostValueAt)传false(默认)。
- 用途区分:预览绘制(
- 计算缓冲复用:
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(惰性启动、析构/结构变化时StopCurveWorkerjoin)。线程只读采样、在锁外计算,算完把结果作为不可变条目(shared_ptr<const CurveEntry>)追加进m_curveEntries,再PostMessage(kMessagePlotDerivedReady)通知宿主重绘。 - 双缓冲:
CurveSpanFor命中覆盖区间的条目就直接用;未命中且窗口大(>kDerivedAsyncWindowThreshold= 65536 采样)时本帧不计算,改用该曲线最新的旧条目顶替(AcquireNewestCurve),同时排一个后台任务;窗口小(≤门槛)仍同步计算以保证预览实时跟随滑块。条目用shared_ptr共享、绘制线程取到后钉进m_curvePins保持整帧有效,后台只追加不改写 → 无数据竞争。 - 一致性与生命周期:后台发布新条目后置
m_curveSceneDirty,下一帧作废静态场景缓存以纳入新曲线;任何会改变数据层结构(增删文件/信号、插入派生信号)的操作前,宿主先调用PlotView::PauseDerivedAsync()(join 后台线程 + 清队列/缓存 + 自增代次),杜绝后台线程读取已释放/已移动的采样。InvalidateDerivedCaches()内部也会PauseDerivedAsync()。 - 精确取值路径(中心轴边沿计数
EdgeCenterCountsFor、取值胶囊需要精确值时)传allowAsyncCompute = false,走同步计算。
- 后台工作线程:
- 实测(离屏,
VERY_BIG_DATA,5 网格;单位 ms/帧):弱波增强平移(整段)2247 → 15;弱波增强拖滑块(整段)2303 → 15;弱波增强静止重绘 0.25;后台结算后的全质量帧 ≈19ms/帧(一次性)。所有交互均 ≤ ~30ms,不再有 300ms/2s 级尖峰。 - 显示效果:小窗口(同步)路径与旧实现逐像素 0 差异;大窗口(异步)路径的曲线像素与旧实现一致(差异仅出现在合成测试中一个与缓存相关的旧实现的时轴陈旧现象上,新实现按设定视图正确绘制)。后台完成后的全质量帧与同步结果相同。
- 代价:大窗口缓存条目可占用较多内存(整段弱波增强单条 ≈104MB/信号),由
kCurveSpanCacheMaxBytes(768MB)+ 条目数上限(32)兜底,最旧优先淘汰。
9.7 完善异步架构 + 预览曲线层级修正(本仓库当前状态)
在 9.6 基础上做了三项关键完善:
- 后台线程池(真·多线程):原先是单线程顺序计算,5 个网格各一条整段弱波增强要 5×368ms≈1.8s,用户会长时间看不到预览曲线(看起来"完全没画出来")。改为
m_curveWorkers线程池(EnsureCurveWorker,线程数 =clamp(硬件并发-1, 1, 4),给 UI 留一个核),多条曲线并行计算,预览曲线因此很快出现。StopCurveWorker会 join 全部线程。后台线程优先级设为THREAD_PRIORITY_BELOW_NORMAL,保证拖动时 UI/绘制线程始终优先获得 CPU(计算只在 UI 空闲时占用),避免"多线程反而抢核导致卡顿"。 - 预览曲线绘制在「本网格全部原信号之上」:原先在同一个信号循环里就地叠加派生曲线,导致网格内有多条信号时,后绘制的原信号会把先前信号的预览曲线盖住(用户明确要求"预览信号处于原信号上层不要被覆盖")。现在
PaintLane分两遍:第一遍画全部原信号(线/散点/光晕),第二遍画全部派生预览曲线。 - 快速帧(关抗锯齿)只用于「会改变画面主体」的拖拽:原先
m_fastFrame = (拖拽中),会给静态场景缓存打上「已降级」标记 → 缓存失效 → 每帧整幅重画。像拖时间光标 / 选区框选 / 拖浮动面板这些只改叠加层、主体不变的交互因此被拖慢(实测拖光标从约 1ms 升到十几毫秒)。现在只有PAN / SCROLL_LANES / LANE_REORDER / MOVE_SIGNAL / BOOST_LEVEL / BOOST_ITERATIONS才算快速帧;其余拖拽直接复用静态场景缓存(贴图 ~1ms)+ 叠加层。实测拖光标 全程命中缓存、约 1–4ms。
- 平移(尤其"一大格=2ms")的固有下限:平移会让视图每帧变化 → 静态场景缓存必然失效 → 每帧都要重画波形(这部分无法异步,因为 GDI+ 绘制必须在 UI 线程;异步只覆盖"派生预览曲线"的计算)。在 5 个网格、含 2576 万采样编码器方波的
VERY_BIG_DATA上,实测拖拽平移约 6–15ms/帧(机器空闲/繁忙分别为下限/上限),已无尖峰。若日后要进一步降低,只能减少波形绘制本身(受"不牺牲显示效果"约束)或把整帧渲染也搬到离屏线程(改动很大、风险高)。 - 预览曲线"看起来没画出来"的真相:对编码器方波这类强信号,弱波增强几乎不改动波形,所以预览曲线与原信号几乎重合(读数胶囊里两者数值只差末位),视觉上"看不见"——这是正确行为。用
mode=1导出可确认曲线确实被绘制(G1–G5 均有浅色叠加 +「(弱波增强)」读数)。小跨度渲染与旧实现逐像素 0 差异。
9.8 预览曲线严格等同「构建为新信号」+ 平移装饰层缓存(本仓库当前状态)
(一)预览曲线与「构建为新信号」逐位一致
- 原实现按「可见区间 ± 保护带」计算预览曲线(近似,边界可能略有差异)。用户明确要求"预览显示效果要和构建为新信号一致"。
- 现改为一律按整条信号计算(
PaintMeanFilterCurve/PaintWeakBoostCurve传begin=0, end=count):与BuildDerivedSignal调用的是同一个ComputeWeakWaveBoost/ComputeMovingAverageIterated、同一段values,因此逐位一致。整段窗口超过异步门槛时由后台线程计算(绘制线程不阻塞)。
(二)平移期间的「装饰层」像素缓存(m_decorDc)
- 实测(
VERY_BIG_DATA,2ms/大格)整幅重绘约 10ms,其中 图例 ≈3.5ms、Y 轴刻度 ≈1.5ms(文字 + 圆角底板),二者合计过半;而它们只随网格几何/信号外观变化,与时间轴视图无关(平移时不变)。 - 实现:每张"全质量帧"后,把图例带(编号徽标 + 底板 + 色块 + 文字)与Y 轴刻度胶囊的像素从后备缓冲区捕获进
m_decorDc;拖拽(快速帧)时跳过重绘、直接BitBlt贴回。用专用指纹SceneDecorHash()(含外观样式、不含派生曲线参数)校验,内容一旦变化即失效并重新捕获,绝不使用过期像素。仅捕获装饰矩形本身(逐胶囊 + 图例带),矩形之间的波形照常绘制,因此无错位/无鬼影。 - 效果:拖拽平移(2ms/大格)整帧 13ms → 约 5ms;0.4% 跨度 20→8ms。注意:该缓存只作用于「拖拽(快速帧)」——静止帧仍按全质量重绘装饰(保证最终画质)。
- 拖弱波增强滑块时,
SceneDecorHash不含派生参数 → 装饰层不会因滑块每帧变参数而失效(否则拖滑块要重绘图例/刻度)。
(三)后台任务去重(避免拖滑块淹没线程池)
- 拖滑块每帧改参数 → 若每帧都入队"整条曲线"任务,会灌满队列、后台线程池被淹没并与 UI 抢 CPU。现按「信号 + 曲线种类」只保留一个待算任务、以最新参数为准(旧的待算任务被移除;正在计算的那个结果由后续条目取代)。
(四)后台线程优先级
- 见 9.7 第 3 点:后台线程为
THREAD_PRIORITY_BELOW_NORMAL,UI 需要运行时永远优先获得 CPU。
(五)平移仍有固有下限(诚实说明)
- 平移必然让视图每帧变化 → 静态场景缓存失效 → 波形必须在 UI 线程重画(GDI+ 只能在 UI 线程绘制,无法异步)。装饰层缓存已把"与视图无关"的部分(图例/刻度)从每帧开销中移除,剩余开销是波形本身 + 网格线 + 时间轴,属"不牺牲显示效果"下的固有成本。
(六)离屏逐像素校验(含历史踩坑提醒)
- 工具:
.preflight/perf/perf_<ver>.exe <csv> 1 <prefix>导出确定性 PNG;.preflight/perf/compare.ps1用LockBits+Marshal.Copy逐字节比对。 - 踩坑:比对若用
[System.Drawing.Bitmap]::GetPixel且两侧位图尺寸不一致或文件缺失,会抛调用"GetPixel"时发生异常:"参数无效"。根因通常是"旧版本"的 harness 链接失败(例如它引用了新版本才有的符号)导致对照图没生成。务必:①先确认两侧 PNG 都存在且尺寸一致;②用LockBits而非逐点GetPixel(后者对上兆像素级图片极慢)。 - 实测结论(
709cbe4的"可见窗口预览" vs389b4ec的"整段预览",同 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 (含整幅重绘放大) -
两个真正的根因:
- 样式补丁用了抗锯齿:拖透明度/粗细/自定义颜色属于"样式变化 → 局部补丁",但它不在任何拖拽态,于是
m_fastFrame=false→ 补丁用抗锯齿重画该网格的包络带(≈72ms/网格)。这是"样式滑块极卡"的直接原因。 - 后台派生曲线发布时整幅作废:拖弱波增强/均值滤波参数滑块时,后台每算完一条就
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 屏一次)时于前台进行,负载下约 30–50ms 的一次尖峰。彻底消除需把"基底渲染"做成不依赖成员状态的独立渲染器再搬后台线程(
PaintLane会写成员缓存,直接后台化有数据竞争,属较大重构)。 - 非拖拽的视界变化(键盘方向键 / 工具栏)仍走整幅重绘(约 125ms);仅鼠标左/中键拖动走宽缓冲快路径。若需让键盘平移也快,需允许非拖拽帧复用"整数像素对齐"的宽缓冲(当前为避免 ≤0.5px 亚像素偏差而未启用)。
- 宽缓冲区重建在视界移出缓冲范围(约每平移 1 屏一次)时于前台进行,负载下约 30–50ms 的一次尖峰。彻底消除需把"基底渲染"做成不依赖成员状态的独立渲染器再搬后台线程(
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 亚像素平移的必然结果,肉眼不可辨);停止平移后的"结算"帧回到像素精确。
- 视界几乎不动(<0.01px,不滚动)时:
-
质量回退检查:
mean_0/1/2、boost_0/1/2、panref七张静态/结算帧与 HEAD 字节完全一致(SHA256 相同)。 -
意外收获:平移中途帧比 HEAD 更干净 —— HEAD 把静态场景(含图例 / Y 轴刻度)直接贴进宽基底,平移后这些像素随之移动,出现重复的网格标题与刻度重影(见对比 PNG);本次改动后同一场景不再出现重影。
9.15 修复「缩放后平移出现拖影/残留/错误元素」与深放大区间仍然卡顿(本轮)
用户方案评估:与当前实现的差异
用户提出的目标是「每个网格各自维护双缓冲区 + 后台线程绘制 + 前台只做贴图/坐标偏移」。 与当前实现的差异与问题点:
| 维度 | 当前实现 | 用户设想 |
|---|---|---|
| 像素复用粒度 | 全局一张「宽基底」+ 场景缓存 + 装饰缓存 + 样式补丁(4 套机制叠加) | 每网格一张信号图层(双缓冲) |
| 绘制线程 | 全部在 UI 线程;只有派生预览曲线在后台算 | 单网格绘制也搬到后台 |
| 失效判定 | 多个指纹(内容/几何/装饰)各自为政,容易漏项(本轮事实上踩了坑) | 每网格一个状态指纹 |
问题点(本轮实证,是本次卡顿与拖影的真正根因):
Tab().LaneScroll()(网格纵向滚动量)没有参与任何内容指纹。 它只改变各网格的绘制位置,却被所有指纹忽略 → 一旦滚动网格(滚轮 / 上下方向键 / 滚动条), 平移宽缓冲区会把旧滚动位置的像素当成当前内容贴回(位置错位、拖影、错误元素); 同时因为指纹没变,"结算"逻辑误以为视界已停 → 每两帧就触发一次整幅重绘(约 125ms), 表现为 8fps 级卡顿。这是用户报告的两个症状的共同根因。FindStyleOnlyLanes()只看SceneGeometryHash(),而该指纹刻意不含视界。 于是视界已经变了却仍走"只重画受影响网格"的局部补丁,把缓存里旧视界的画面 (时间轴、其它网格、底色)留在屏上 → 拖影 / 残留。InitWideBufferFromScene()把"上一帧的整幅画面"拷进宽基底,而整幅画面里含装饰 (Y 轴刻度胶囊 + 图例),宽基底却不含 → 装饰像素随平移一起移动,在网格左缘之外 留下一排旧图例/刻度(重影)。- 装饰缓存的两个不变量被打破:贴回时用的是当前帧重建的矩形列表(命中缓存时根本不会重建 → 为空);
并用当前帧的
m_area原点去推算源偏移(两帧原点可能不同)→ 表现为装饰消失 / 黑块 / 整体错位。
本轮修复
SceneContentHash()计入Tab().LaneScroll()(根本修复:滚动网格不再复用旧位置像素, 也不再误触发"结算")。FindStyleOnlyLanes()增加视图几何逐项校验(视界 / 滚动 / 布局 / 缩放 / 拖拽光晕)后才允许局部补丁。- 宽基底不再用"含装饰的整幅画面"初始化;
RebuildWideBuffer()以m_renderWide跳过装饰, 保证基底永远不含装饰。 - 宽路径的装饰改为按当前视界重画(新增
m_renderDecorOnly复用与整幅渲染完全相同 的PaintLane代码路径,只跳过网格线与波形)。 - 装饰缓存修好两个不变量:每次真正绘制装饰的帧都立即重新捕获(不再只在全质量帧捕获), 且捕获时同时保存矩形列表与坐标原点,贴回时用保存的这份、按绝对坐标写入。
- 装饰底板改为不透明(原来是 94% / 91%):装饰像素因此与时间视界无关,可安全跨视界复用; 半透明底板会把旧视界的波形混进缓存像素,平移时在底板内部留下残影。
- 新增离屏诊断模式(
.preflight/perf/perf_main.cpp的mode=6):复现"加载 → 缩放 → 平移/键盘/滚轮" 全部交互,逐帧计时,并把"当前屏"与"从零整幅渲染"做逐像素对比 + 差异包围盒。 这是本轮定位问题的关键工具(否则只能靠猜测)。
实测(VERY_BIG_DATA.CSV,视界 [64.3948, 64.4153]s ≈ 2.045ms 一大格,5 网格,ms/帧)
| 交互 | 修复前 | 修复后 | 像素差(对比从零整幅渲染) |
|---|---|---|---|
| 键盘左右方向键平移 | 11~19ms | 5.2ms | 3705 通道(≤0.5px 亚像素偏移,见下) |
| 键盘上下方向键滚动网格 | 0.9ms(但错位/偶发 125ms) | 0.5ms | 0 |
| 中键水平拖动 | 10~18ms | 5.2ms | 357 通道 |
| 中键纵向拖动 | 14~19ms | 6.0ms | 72 通道 |
| 左键水平拖动 | 9~17ms | 4.5ms | 390 通道 |
| 左键纵向拖动 | 15~19ms | 6.1ms | 72 通道 |
| 滚轮滚动网格 | 1.0ms | 0.6ms | 0 |
| 缩放后再平移 | 9~17ms | 3.7ms(缩放帧 6.8ms) | 495 通道 |
| 弱波增强预览 + 平移 | 15~18ms | 5.8ms | 2264 通道(异步曲线发布时序) |
- 修复前的纵向拖动会产生 2 万+ 通道的残留(黑块 / 标签残缺);修复后降到 72 通道(仅亚像素量化)。
- 剩余的几百通道差异集中在密网格/密波形上,是"宽基底按整数像素平移(≤0.5px 亚像素误差)" 的必然结果,肉眼不可辨;视界停下后的"结算"帧会回到像素精确。
「每网格双缓冲 + 后台绘制」架构(已落地,本轮同批提交)
把原来全局一张「宽缓冲区」重构为用户提出的方案:
- 每网格一块「信号图层」(
LaneLayer):宽度 = 视界宽 ×3(左右各多画半屏),只含该网格基底 (底色 + 网格线 + 波形 + 派生曲线),不含装饰(Y 轴刻度 / 图例,它们锚定网格左缘、不随时间视界移动)。 - 双缓冲:
m_layerFront(前台只读)/m_layerBack(后台填充)。后台画完整块后std::swap整体交换;前台永远能拿到立即可用的像素,后台正在绘制也不影响交互。 - 后台绘制:
m_layerWorker线程(BELOW_NORMAL 优先级)按任务队列逐网格重画。 前台每帧只做:① 判断各网格图层是否就绪;②BitsBlt把就绪且跨度一致的图层按坐标偏移贴到画面; ③ 画时间轴 / 派生叠加层 / 装饰 / 叠加层。前台不再绘制任何波形。 - "未就绪就用旧数据应付":只要图层跨度一致、能覆盖当前视界,即使内容稍旧也照贴
(
LayersReadyForView()不比较内容指纹),内容由后台补画后交换 —— 于是样式/参数变化、 平移、滚动都不会退化成整幅重绘。只有"跨度变了 / 图层还没建"才落到整幅重绘一次以保证正确性。 - 不收敛保护:若同一状态反复排任务仍与前台不一致(说明某参与指纹的状态没被正确还原), 超过 90 次即改为同步重建一次强行收敛,避免死循环。
- 前台优先:后台在"前台最近 30ms 内绘制过"时主动让路(把任务放回队首,8ms 后再试),
避免与前台抢
m_renderMutex反而更卡;交互停下后后台从容补画。 - 加锁顺序:一律
m_renderMutex→m_layerMutex(后台与前台一致),避免 AB-BA 死锁。 - 渲染状态还原:图层渲染会临时改写
m_area/m_plotRect/ 视界 / 网格矩形, 结束后必须调用BuildLaneVisuals()重算m_laneContentHeight并还原LaneScroll与各网格幅值范围 —— 否则内容指纹与前台永远不一致 → 图层被判定"永远过期" → 死循环(本轮踩过这个坑)。
验证(离屏,perf.exe ... 6,视界 ≈ 2.045ms 一大格):
| 交互 | 帧平均 | 像素差(对比从零整幅渲染) |
|---|---|---|
| 键盘左右方向键平移 | 3.5ms | 3705 通道(≤0.5px 亚像素) |
| 键盘上下方向键滚动网格 | 0.7ms | 0 |
| 中键水平拖动 / 左键水平拖动 | 3.3 / 1.8ms | 357 / 0 通道 |
| 中键纵向 / 左键纵向拖动 | 5.7 / 5.5ms | 72 通道 |
| 滚轮滚动网格 | 1.9ms | 0 |
| 缩放后再平移 | 4.6ms(缩放帧 5.0ms) | 549 通道 |
| 弱波增强预览 + 平移 | 8.7ms | 3590 通道(异步曲线发布时序) |
- 「背景图层绘制次数」计数可直接证明单网格基底的绘制确实发生在后台线程:一轮诊断累计 18~45 次; 前台只在做位图搬运。
- 跨进程验证:
BackgroundLayerRenderCount()为公开只读接口,perf.exe ... 6结束时会打印总量。
10. 变更日志(本文件维护记录)
| 日期 | 设备/模型 | 摘要 |
|---|---|---|
| 2026-10-11 | DeepSeek-V4.1-Flash | 新建本文件;记录项目要点与规范;修复「实验性功能」二级菜单瞬间关闭问题。 |
| 2026-10-11 | DeepSeek-V4.1-Flash | 修复右键网格菜单后悬停光晕/提示气泡残留;修复「启用带预览曲线的实验性功能后平移/拖光标 300ms+ 卡顿」(paintDerivedCapsule 巨窗每帧重算,收窄请求窗口,显示效果零变化)。 |
| 2026-10-11 | DeepSeek-V4.1-Flash | 深度修复「实验性功能预览曲线导致的交互卡顿」:大窗口改入缓存(静止重绘 2247ms→0.25ms)、交互期间延迟重算(弱波增强拖滑块 2303ms→18ms)、计算缓冲复用;显示逐像素不变。 |
| 2026-10-11 | DeepSeek-V4.1-Flash | 架构升级:派生曲线计算与 UI 绘制分离(后台线程 + 双缓冲)。绘制线程永不等计算,弱波增强拖滑块/平移从 2303/2247ms 降到 ~15ms;结构变化前 PauseDerivedAsync 保证线程安全;小窗口同步路径与旧实现逐像素 0 差异。 |
| 2026-10-11 | DeepSeek-V4.1-Flash | 完善异步:后台改多线程池(预览曲线并行计算、更快出现);PaintLane 分两遍把预览曲线画在全部原信号之上(不再被覆盖);快速帧只用于改主体的拖拽(拖光标/框选改为命中场景缓存,约 1–4ms)。小跨度逐像素 0 差异。 |
| 2026-10-11 | DeepSeek-V4.1-Flash | 预览曲线改为按整条信号计算,与「构建为新信号」逐位一致;新增平移装饰层像素缓存(图例 + Y 轴刻度在拖拽时直接贴回,2ms/大格拖拽 13ms→约 5ms);后台任务按 (信号,种类) 去重以防拖滑块淹没线程池。 |
| 2026-10-11 | DeepSeek-V4.1-Flash | 探针定位"样式/参数滑块在深放大下极卡":根因是样式补丁用了抗锯齿(≈72ms/网格)且后台曲线发布时整幅作废导致每帧整幅重绘。修复:样式补丁强制快速帧、拖拽中不整幅作废、交互期省略面板投影 → 透明度/粗细/颜色 77ms→16ms、弱波增强滑块 96–176ms→26ms,静止/结算帧逐像素不变。 |
| 2026-10-11 | DeepSeek-V4.1-Flash | 落地用户提出的复用像素方案:新增平移宽缓冲区(以视界为中心左右各多渲染半个视界宽),平移时前台只做一次 BitBlt 截取子矩形(O(1)),移出边界才重建 → 深放大下平移 34ms→1.3ms;静止/结算帧逐像素 0 差异。 |
| 2026-10-11 | DeepSeek-V4.1-Flash | 把宽缓冲推广为基底复用:抽出 PaintLaneDerivedOverlays、宽缓冲只存基底(不含派生曲线),基底键排除派生参数 → 拖弱波增强滑块 55ms→5.2ms;平移 1.3ms、样式 8ms 不变;静止帧逐像素 0 差异。残留:宽缓冲重建在前台(约每屏一次)、非拖拽视界变化仍整幅重绘。 |
| 2026-10-11 | DeepSeek-V4.1-Flash | 宽缓冲改为滚动式(自贴图平移 + 只补画新暴露细条),消除"移出边界整幅重建"的周期性尖峰;缓冲只覆盖绘图区、时间轴前台绘制、亚像素误差用锚点限制在 ≤0.5px → 持续拖动平移约 6ms/帧、无尖峰(长距最大 10.5ms);静止帧逐像素 0 差异。残留:每次开始拖拽的首帧需新建缓冲(一次启动尖峰)。 |
| 2026-10-11 | DeepSeek-V4.1-Flash | 消除"开始拖拽"的首帧尖峰:平移首帧用静态场景缓存初始化宽基底(InitWideBufferFromScene,贴图 + 细条,不再从零重画 2×视界);启用带预览的实验性功能时预建基底。实测:平移拖动最大 8.5ms、弱波增强滑块最大 10.8ms(原分别 35/37.6ms),用户全部鼠标交互在该放大区间全程 ≥60fps、无尖峰;静止帧逐像素 0 差异。残留:非拖拽视界变化(键盘/工具栏)仍整幅重绘。 |
| 2026-10-11 | DeepSeek-V4.1-Flash | 消除 9.13 残留:非拖拽视界变化(键盘/工具栏)也走宽基底快路径(124.6ms→8.6ms),并用 wideKey 相等判定"视界已停止变化"触发一次结算整幅全质量帧以恢复像素精确;分支顺序固定为"静态贴图 → 宽基底 → 样式补丁 → 整幅",宽基底强制全质量抗锯齿且竖向刻度用前台跨度。新增像素对比模式验证:底座管线忠实(meanAbs 0.68/255),平移期仅 ≤0.5px 亚像素差;7 张静态/结算帧与 HEAD 字节一致;平移中途帧不再有图例/刻度重影。 |
| 2026-10-12 | DeepSeek-V4.1-Flash | 落地用户提出的「各网格独立双缓冲区 + 后台线程绘制」架构:把全局宽缓冲区重构为每网格信号图层(宽×3,只含基底不含装饰)、m_layerFront/m_layerBack 双缓冲 + m_layerWorker 后台线程(前台只做 BitBlt 坐标偏移,不再绘制波形);前台"图层就绪即用现成像素、内容稍旧也照用,交后台补画后交换";加不收敛保护、前台优先让路、固定加锁顺序(render→layer)、渲染状态完整还原(含 m_laneContentHeight/LaneScroll)。新增 BackgroundLayerRenderCount() 公开计数用于验证后台绘制确实发生(一轮诊断 18 |
| 2026-10-12 | DeepSeek-V4.1-Flash | 修复用户报告的「缩放后平移出现拖影/残留/错误元素」与「2ms 一大格区间仍然卡顿」。根因:①网格纵向滚动量未参与任何内容指纹→滚动时把旧位置像素贴回且每两帧触发一次整幅重绘(≈8fps);②FindStyleOnlyLanes 未校验视界→视界变化后仍走局部补丁;③宽基底用含装饰的整幅画面初始化→装饰随平移重影;④装饰缓存的矩形列表与坐标原点两帧不同源→装饰消失/黑块/错位。修复后交互帧 9perf.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。 |
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 实测;违反必出缺陷)
PlotView::Paint()分支顺序固定:LANE_REORDER→ 静态场景贴图 → 每网格图层(宽基底) → 样式补丁 → 整幅重绘。勿改序。- 场景贴图必须在图层之前(否则鼠标悬停每两帧触发一次"结算整幅重绘",反而更卡);
- 图层必须在样式补丁之前(拖弱波增强 / 均值滤波参数只改派生曲线,走图层更快且质量更高)。
- 每网格图层只含基底(底色 + 网格线 + 波形 + 派生曲线),不含装饰(Y 轴刻度 / 图例 —— 它们锚定网格左缘、不随时间视界移动)。
- 图层位图宽度 =
width + 2*max(1, width/2)≈ 2 × 视界宽(左右各半屏,可平移 ±半屏)。 RenderLaneLayerRegion结束后必须BuildLaneVisuals()(重算m_laneContentHeight)再还原LaneScroll与各网格yMin/yMax;漏还原任何一项 → 算出的图层指纹与实际渲染不符 → 每帧重复重画(白耗性能;旧版有后台线程时会死循环卡死)。- ⚠️ 图层渲染一律在 UI 线程内同步完成(
Paint全程持有m_renderMutex)。绝不要把图层渲染搬到后台线程:它会临时改写m_area/m_plotRect/Tab().ViewMin()/ViewMax()/各网格矩形,而这些状态被 UI 线程的消息处理无锁读/写(平移读视界、命中测试读网格矩形),后台的"保存→改写→恢复"会覆盖用户正在进行的平移(视界弹回)并让命中测试错位 —— 这就是 2026-10-12 移除后台图层线程的原因(详见 §12 本轮)。 - 每个图层记录自己的时间映射
LaneLayer::wideMin;滚动与贴图必须逐网格按各自wideMin计算,绝不共用一个全局锚点(否则各网格被平移不同的真实距离 → 网格间时间错位)。补细条时必须传layer.wideMin(不能用"当前视界"的映射,否则有 ≤0.5px 接缝与抖动)。 - 加锁顺序恒定
m_renderMutex(recursive)→m_layerMutex(图层不再有跨线程访问,但顺序约定保留)。 - ⚠️ 前台刷新线程绝不做“整批图层重绘”:平移/拖动期间
RollWideBuffer()只做逐网格整数像素平移 + 补细条;内容过期 / 锚点过远的网格只登记到m_layerRebuildPending,由RenderPendingLaneRebuilds(budget)本帧至多重画 1 个,其余下帧继续(并PostMessage(kMessagePlotDerivedReady)请求重绘)。图层没覆盖到的部分BlitWideBuffer只贴有像素的那一段(其余留给底色,下帧补齐),不得整块跳过(跳过 = 网格变空 / “断线”)。 - ⚠️ “结算”(整幅全质量精确帧)只能在
m_dragMode == NONE且全部图层就绪时发生:否则按住键拖拽时会在“宽基底快路径”与“整幅重绘(拖拽时关 AA)”之间每两帧振荡 → 波形质量轮流变差 / 抖动 / 周期性卡顿(用户报告的“按住拖拽时显示效果下降、出现断线,松手才恢复”)。 - GDI 绘制不受 GDI+
SetClip约束:UiText(DrawTextW/ETO_CLIPPED)与UiFillRect的 GDI 快路径(不透明且面积 ≥ 20000px²)都会画出裁剪区;宽矩形 / 文本必须自己夹取,GDI 批绘必须显式传 clip。 SceneContentHash必须覆盖Tab().LaneScroll()(网格纵向滚动量)与SelectedRef();FindStyleOnlyLanes()必须逐项比较SceneCacheKey(SceneGeometryHash()刻意不含视界,只比它不足以判定"视图没变")。- 随鼠标变化的绘制(悬停高亮 / 光晕 / 图例高亮 / 光标读数 / 选区 / 提示 / 面板悬停)必须同时加在"缓存贴图快路径"与"整幅重绘路径"两侧。
- 浮动面板必须按层序(
m_panelOrder,队尾 = 最上层)绘制与命中:命中从后往前取第一个命中者,点击面板BringPanelToFront(),新开面板追加到队尾。不要再按kEdgePanelKinds固定顺序命中再 break(否则上层 ✕ / 标题栏会被下层参数输入框抢走 → “上层面板关不掉 / 拖不动”)。 - 不要为波形构建 / 绘制引入多线程;已落地的多线程只有派生曲线线程池(
m_curveWorkers)。任何"移动 / 释放采样"的操作前必须PauseDerivedAsync()。 - MinGW 的
swprintf按 C99:宽格式串里的%s= 窄字符串,宽字符串必须用%ls(否则 "KB" 会被截成 "K")。 - 源文件名必须全局唯一(Makefile 按 basename 去重 + vpath 查找);新增目录要同时登记进
CXX_INCLUDES_PATHS/CXX_SOURCES_PATHS。
11.3 离屏验证方法学(本机偏好,强烈建议沿用)
- 不要占用用户的鼠标 / 键盘 / 屏幕:优先
.preflight/uitest(离屏 harness:SW_HIDE窗口 + 直接注入消息 +BitBlt从m_backDc截图)或.preflight/perf(离屏计时 / 逐像素对比)。act.ps1/real.ps1会SetCursorPos/ 抢焦点 / 钉窗口,仅当缺陷与捕获 / 焦点强相关且无法离屏复现时才用。 - 验证"某交互不应改变外观"用像素哈希 / 逐像素比对(
hashregion.ps1、compare.ps1),只对不受该交互影响的区域取样。 - 测耗时用
QueryPerformanceCounter打点并批量写日志(探针里每帧fopen/fprintf会给单帧凭空加几十毫秒)。 - 大文件压测用
TestData/VERY_BIG_DATA.CSV(1.77GB / ≈2580 万采样 / 5 信号);计时前先把视界精确复现到被测区间。 .preflight/已被.gitignore忽略;探针与脚本不要入库。- PowerShell 终端:多行粘贴不可靠(写成
.ps1再执行);git commit -m多行会被截断(改用git commit -F 文件);终端里不要写非 ASCII 字面量(会被二次编码成乱码),中文一律用文件编辑工具写入。
12. 起源设备详细工程记忆(原文附录)
以下为起源设备(本机)Agent 的详细工程记忆原文,与 §1–§10 同处一个记忆文件(不再另建文件)。 ⚠️ 其它设备更新本文件时请保留本节;可继续在本节之后追加。
§12 阅读指引
- §1–§10 是跨设备共享的精简权威备忘(项目目标、结构规范、性能架构演进);§12 是本机视角的 详细现场经验(逐项功能约定、UI / 交互硬约束、验证脚本与坐标、Git / 终端陷阱)。重叠处以 §1–§10 为准。
- §12 内的小节编号(如
## 架构、## 性能铁律、## 验证手段)自成一套体系,与 §1–§10 的编号无关,按标题名理解。 - §12 内容 = 本机 Agent 的仓库记忆原文(
/memories/repo/just-datainspector.md),会随本机工作持续更新。 - 本机工具链与高风险硬约束摘要见 §11;§12 开头的「架构已变更」是合并远端
f97c4cf后的新架构(最优先阅读)。
⚠️⚠️ 架构已变更(2026-10-12 合并远端 f97c4cf):先读这一节
本仓库现有跨设备共享记忆
Documents/agent_mem.md(另一台设备维护、已入库)。接手前先读它。 本节记录合并后本机必须知道的新架构;下面旧章节里"静态场景缓存是唯一快路径 / 只有一张全局宽缓冲"的描述已过时。 合并方式:git merge --ff-only gitea/main(远端 16 个提交线性追加在本机7608e85之后,合并后 HEAD =f97c4cf)。
Paint() 分支顺序(硬约束,勿改序)
LANE_REORDER 条带重排 → 静态场景缓存贴图 → 每网格信号图层(宽基底) → 样式补丁 → 整幅重绘。
- 场景贴图必须在图层之前:视界/样式都没变时(典型「鼠标悬停移动」)必须命中贴图, 否则每两帧触发一次「结算整幅重绘」,悬停反而更卡。
- 图层必须在样式补丁之前:拖弱波增强/均值滤波参数只改派生曲线,走图层比逐网格补丁更快、质量更高。
每网格「信号图层」(取代原先唯一的那张全局宽缓冲区)
LaneLayer(PlotView.hpp):m_layerFront前台只读 /m_layerBack后台填充,双缓冲;m_layerWorker后台线程(THREAD_PRIORITY_BELOW_NORMAL)逐网格重画,画完std::swap交换。- 图层只含基底(底色 + 网格线 + 波形 + 派生曲线),不含装饰(Y 轴刻度 / 图例)—— 装饰锚定网格左缘、会随平移重影,改由「装饰层缓存」在前台绘制。
- 位图宽度 =
plotWidth + 2*max(1, plotWidth/2)≈ 2 × plotWidth(左右各半屏,可平移 ±半屏)。 ⚠️ 头注释与提交信息写「宽 ×3」是错的(代码就是 ×2)。 RenderLaneLayerRegion(laneIndex, layer, stripX, stripW):临时改写m_area/m_plotRect/ViewMin/ViewMax后用与整幅完全相同的PaintLane渲染;m_renderWide=true跳过派生曲线与装饰; 强制m_fastFrame=false(全质量 AA);竖网格刻度沿用前台视界跨度(否则竖线只剩 1/3)。 ⚠️ 结束后必须BuildLaneVisuals()(重算m_laneContentHeight)再还原LaneScroll与各网格yMin/yMax;少还原任何一项 → 前后台指纹永不相等 → 图层永远"过期" → 每帧重复排任务 → 界面卡死。RollWideBuffer():视界移动时整数像素自贴图平移 + 只补画新暴露的细条(每帧 O(细条),无周期性尖峰)。LayersReadyForView()不比较内容指纹(只要求跨度一致、能覆盖视界)→ 前台「内容稍旧也照用」; 不一致的网格由ScheduleStaleLayerRenders()交后台补画(按laneIndex去重);m_layerRescheduleStreak > 90→ 同步RebuildWideBuffer()强行收敛(防死循环)。- 加锁顺序恒定
m_renderMutex(recursive)→m_layerMutex;反序 = AB-BA 死锁。 后台「前台优先让路」:GetTickCount64() - m_lastPaintTick < 30ms就等 8ms 再重排队。 - 后台完成后
PostMessageW(host, kMessagePlotDerivedReady);Application只InvalidatePlotRect, 且该消息不置m_chromeDirty。 BackgroundLayerRenderCount()= 公开只读计数(验证后台确实在画)。
「结算」帧(保证最终像素精确)
wideKey = SceneContentHash(true) ⊕ llround(ViewMin*1e6) ⊕ llround(ViewMax*1e6)(FNV 混合);
若与上一条快路径帧相同 → 判定视界已停 → 放弃快路径、落到整幅重绘一次全质量精确帧。
(图层按整数像素平移,存在 ≤0.5px 亚像素偏差,必须有这一次结算。)
指纹(三套,勿混淆)
SceneContentHash(includeStyles=true, includeDerived=true);SceneGeometryHash() = SceneContentHash(false)(含派生参数、不含逐信号外观);SceneDecorHash() = SceneContentHash(true, false)(含外观、不含派生参数 → 拖参数滑块不使装饰失效);LaneLayerKey(i) = SceneContentHash(true,false) ⊕ (i+1)*k ⊕ span ⊕ plotRect.Width。- ✅
Tab().LaneScroll()已进指纹(本轮 bug 根因之一:不进指纹 → 滚网格时把旧位置像素贴回 + 误触发结算)。 FindStyleOnlyLanes()现在额外逐项比较SceneCacheKey(视界/滚动/布局/缩放/光晕)——SceneGeometryHash()刻意不含视界,只比它不足以判定"视图没变"。
装饰层缓存 m_decorDc(Y 轴刻度胶囊 + 图例)
- 每一帧"真正绘制了装饰"都要
CaptureDecorCache()(不再只在全质量帧捕获); 同时保存m_decorCapturedRects(矩形列表)与m_decorOriginX/Y(m_area原点), 贴回时必须用保存的那一份(命中缓存时本轮不会重建矩形列表 → 用当前列表会贴空/贴错)。 - 装饰底板已改为不透明(原 94%/91%)→ 装饰像素与时间视界无关,可安全跨视界复用。
派生曲线的后台计算(另一套线程,与图层线程独立)
m_curveWorkers线程池(线程数clamp(硬件并发-1, 1, 4),BELOW_NORMAL);m_curvePins钉住本帧用到的条目。PauseDerivedAsync()必须在任何"会移动/释放采样"的操作前调用:Application的RemoveFile/RemoveSignal/ 加载线程完成后AddDataFile/ 插入派生信号。- 预览曲线一律按整条信号计算(与「构建为新信号」逐位一致)。
- 大窗口缓存不再按采样数排除(
kCurveSpanCacheMaxSamples提到 64M),改用总字节预算(≈768MB)+ 条目数上限淘汰。
浮动面板像素图层(7608e85 已有,合并后仍在)
m_panelDc + m_panelSlots;PanelGlobalKey()/PanelSlotKey();滑块数值只对 BOOST 计入面板键;
某个面板重画后,所有"在其后绘制且与之重叠"的面板也必须重画(级联 dirtyRects)。
随同合并上来的其它修复
PopupMenu::OnMouseMove:子菜单展开期间,指针仍在「主菜单 ∪ 子菜单」内且未落到其它可点击条目 → 不收起 (修「移向二级菜单途中被误关闭」,CPU 快 / 鼠标事件密集时必现)。Application在绘图区右键弹网格菜单前调m_plot.OnMouseLeave()清悬停;PlotView::OnMouseLeave()补清m_hoverLegend(修「菜单打开后光晕/提示气泡残留」)。DataModel:ComputeWeakWaveBoost/ApplyWeakBoostStage/ComputeMovingAverageIterated的工作缓冲改thread_local复用;ApplyWeakBoostStage(input, strength, output, extremes)多一个extremes出参。- ⚠️ 另一台设备的工具链不同(MinGW-Builds 16.1.0 seh-rev0);本机是 GCC 15.2.0 UCRT。
Makefile 未变(仍
-lgdiplus -lcomdlg32 -ld3d11 -ldwmapi -ld3dcompiler),本机make release已验证通过。
⚠️ 性能铁律(b960b03 实测,务必先读)
本项目的性能瓶颈几乎总是「GDI+ 抗锯齿矢量绘制在复杂路径上耗时爆炸」,而不是 CPU 逻辑。
排查这类卡顿的顺序:① 先写离屏探针量化(Application::Render() 直接调用 + QueryPerformanceCounter
计时;#define private public 后可直接调 BuildPolyline/PaintLane/PaintDerivedCurve);
② 再对症把"复杂路径"换成"廉价表示"。
- 包络带必须做 ±1 列极值扩展(
EmitEnvelopeBand):信号振荡周期与像素列宽相当时 (编码器方波 2ms/大格 ≈ 3 采样/列),逐列包络会在"全高/极窄"间交替 → 1224 列里 988 处 全高跳变 → GDI+ 扫描线填充 27.6ms;±1 列扩展后 1.1ms(25 倍),纵向仅多 1px。 ⚠️ 这是"某些放大等级下平移特别卡"的根因(2ms/大格 87ms vs 其它等级 17~20ms)。 - 派生预览曲线(均值滤波/弱波增强)稠密时必须走逐像素列包络带,不要用抗锯齿折线:
折线在方波上实测 12
42ms/条(5 网格上百毫秒);包络带 FillPath 0.561.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 (探针点要选在绘图区左留白,那里没有任何悬停反馈;选信号面板会因面板行悬停而误报)。 -
浮动面板 ✕(三个缺陷一起修):
layout.close在layout.title矩形内部,按下 ✕ 后会继续落到 「标题栏 → 拖动面板」分支 → "按住 ✕ 却拖走面板"。→ 按下 ✕ 后直接 return true。- ✕ 悬停不显示高亮:✕/滑块/按钮的高亮都靠
m_mouseX/m_mouseY,但从同一网格的波形 移到面板上时m_hoverLane不变 →OnMouseMove返回 false → 宿主不重绘。 → 新增m_mouseOverPanel,(overEdgePanel || m_mouseOverPanel) && mouseMoved时强制返回 true。 ⚠️ 离屏 harness 直接调app.Render(),测不出"没有请求重绘"这类缺陷 → 必须直接断言plot.OnMouseMove(x,y) == true。 - ✕ 的光标改为
IDC_HAND(与其它 ✕ 一致)。⚠️ 早期记忆里"面板 ✕ 不改光标"已被用户推翻。
-
harness 维护提示:
.preflight/uitest/main.cpp曾被外部工具双重行距化 (每行后插一个空行,注释头也被搬动);用 Pythontext.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必须 mixweakBoost/weakBoostLevel/ 每个条目 ref。- 构建:
PlotView::WeakBoostBuild{fileIndex,signalIndex,levelPercent};Application::DerivedSignalRequest{fileIndex,signalIndex,points,order,levelPercent,weakBoost}+BuildDerivedSignal(request, makeVisible);命名<原信号> (弱波增强 M%)。 - 光标胶囊尾缀「弱波增强」;
paintDerivedCapsule已改为直接收曲线引用 + 尾缀 (中心轴 / 均值滤波 / 弱波增强三者共用,不再传 first/second/diffAmp)。
拖拽光标越出视界(再次出现,已修)
- 根因:
CURSOR_MOVE里target = XToTime(fx − m_cursorGrabOffset),而抓取读数标签时 grabOffset 很大(标签比光标线宽),放大到一定程度后该偏移换算成的时间会落到视界之外;ClampCursorToView当时只在CycleCursorMode中调用过一次,拖拽路径根本没调用。 - 修法:
CURSOR_MOVE赋值后ClampCursorToView(which);CURSOR_SPAN_MOVE整体夹取 (两光标间隔本身大于可视跨度时退化为各自夹取)。 - 视图变化(平移/缩放/自适应)仍然不移动光标:贴边读数 + 方向三角是刻意行为,勿改。
- 复现/回归用例见
[Z]:视窗放大到 0.001×总跨度,抓标签左缘后向左右各拖 500px。
验证(离屏 harness .preflight/uitest/main.cpp,窗口 SW_HIDE、不动鼠标键盘屏幕)
[X]弱波增强算法/面板/滑块/构建:等级 0/50/100% 的目标值、健康区逐位不变、平信号不变、 滑块按压 25%/拖到 75%/拖出左端钳制 0%、缓存键(信号+等级)、SceneContentHash 失效、 构建逐位一致且独立成网格。[Y]合成调幅纹波端到端:弱周期峰峰值 100→160(等级 50%),健康周期 200 不变。[Z]光标拖拽始终落在 [ViewMin, ViewMax] 内。- 合计 295 项检查全部通过(exit=0)。构建:
make release+.preflight/uitest/build.ps1。
工具经验(本轮)
git diff --stat不能判断工作区行尾风格:core.autocrlf=true会把 CRLF 归一化后再比对。 想查真实行尾用git ls-files --eol <path>(正常应为i/lf w/crlf); DataModel.cpp 曾被改成整文件 LF,用 Python 字节级替换\n→\r\n复原即可。- 拼接大段测试代码:把新代码写进
.preflight/uitest/*.txt,再用 Python(newline=""保留 CRLF) 按“\t// [X] ...注释行 + 其上一行 banner”定位并替换,比手写超长 oldString 稳得多。
历史存档(「差异放大」已在 67f659a 中移除,以下仅记录当时的实现,勿照搬)
差异放大算法(jdi::ComputeDiffAmplify(values, window, gain, out),DataModel.cpp)
- 目标:纹波信号里某个周期(或半周期)的 M 形下凹明显偏弱时,把它拉回周边振幅, 且其它数据点一点都不动。参数:窗口点数 X(= 窗口长度)、放大倍数 N(增益上限)。
- 最终算法(全 O(n)):
trend= 半径2r的滑动平均(≈ 一个纹波周期)→ 纹波中心线; 按整周期取平均时,一个"变扁"的周期对趋势线的贡献恰好等于正常周期,不会把趋势线拉偏。r = (X-1)/2,X应略大于纹波周期的一半(窗口跨相位 ≥180° 才与相位无关)。strength= 邻域内|value − trend|的最大值(SlidingMax),即局部纹波振幅。target=strength的 75 分位(直方图,O(1) 额外内存),统计时排除首尾端点收敛区。- 逐点
gain = clamp(target/strength, 1, N);输出= trend + gain*(value − trend), 再夹到周边包络[envMin, envMax](半径 8r)内。 增益下界恒为 1 ⇒ 正常区域 gain 恰好 1,输出与输入逐位相同(基准被消掉)。 - 端点收敛:首尾各
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+Render7.1 → 3.15ms, 缓存命中帧 2.06 → 0.30ms。
加载进度显示字节数
CsvProgress由std::function<bool(double)>改为std::function<bool(const CsvProgressInfo&)>,CsvProgressInfo{ratio, bytesRead, totalBytes, hasBytes};列重组阶段hasBytes=false(只显示百分比)。Utf8Util::FormatByteSize(unsigned long long)→ 自适应 GB/MB/KB(1024 进制,%.2f)。- ⚠️⚠️ MinGW 的
swprintf按 C99 语义:宽格式串里的%s表示窄字符串, 写%s传wchar_t*会把 "KB" 截成 "K"。宽字符串必须用%ls。 - 浮层文本:
FormatByteSize(read) + " / " + FormatByteSize(total) + " " + "%.2f%%"。
光标读数(常规光标)
- 双光标重叠时按时间值排序:小值在上、大值在下(原来固定把光标 2 上移,左右交换后是错的)。
- 新增
CursorOffViewSide(which, &offRight)判定读数是否被钉在视界边缘;PaintCursorOffViewTip()在胶囊上方画方向三角。视界之外时叠放间距改为kCursorTipStackGap = arm*1.4 + 4,让三角正好落在两行之间的空隙(否则压在相邻胶囊上)。 - 双光标同时越界到同一侧:差值读数与两条时间读数同列垂直排列(
CursorSpanRect新增分支), 并且差值读数也画方向三角。
中心轴边沿计数与均值滤波统一
Lane::edgeCenterSmooth→std::vector<Lane::EdgeCenterItem> edgeCenterItems(using EdgeCenterItem = MeanFilterItem,字段同为points(X)/order(N)), 新增Find/Ensure/RemoveEdgeCenterItem/EdgeCenterEnabled/ToggleLaneEdgeCenterSignal。- 曲线统一走
MeanFilterCurveFor(signal, X, N)(删掉CenterCurveFor/CenterValueAt/m_centerCurveCache/PaintCenterCurve),EdgeCenterCountsFor(file, signal, X, N, ...)。 - 面板:CENTER 与 MEAN 共用「逐条目」排布(
itemized),每条目三排 (名称+复选框 / X·N 输入框 / 中心轴的统计结果),未勾选只画第一排;EdgePanelLayout新增rowStats。PlotEditTarget::EDGE_CENTER_SMOOTH拆成EDGE_CENTER_POINTS/EDGE_CENTER_ORDER;中心轴面板不再有底部编辑行。 - 同步点:
LaneCarry/SplitLane/RemapAfterSignalInsert/RemapAfterSignalRemoval与SceneContentHash都要带上edgeCenterItems。
构建
- 根目录执行
make release/make debug/make all/make clean(MinGW-w64 15.2.0 UCRT,make 4.4.1)。 - 产物:
Build/Release/Target.exe、Build/Debug/Target.exe(GUI 子系统,无控制台)。 - Makefile 已加入
-lgdiplus -lcomdlg32;入口点是WinMain(Makefile 未使用-municode)。
结构约定(必须遵守)
Components/<组件名>/Inc|Src,Utilities/Inc|Src;文件名只能用字母+下划线。- 新增目录必须同时补进 Makefile 的
CXX_INCLUDES_PATHS/CXX_SOURCES_PATHS。 - 目标文件按 basename 去重且用 vpath 查找 → 所有源文件名必须全局唯一。
- 头文件保护宏格式:
__文件名全大写_HPP__(双边双下划线)。 - Makefile 用
-finput-charset=UTF-8 -fexec-charset=GBK -fwide-exec-charset=UTF-16LE:源码 UTF-8,宽字符串字面量可直接写中文。
代码要点
- 命名空间
jdi;namespace gd = Gdiplus;(定义在 Theme.hpp)。 - UI 为单窗口手绘 + 区域命中测试(Toolbar/SignalPanel/PlotView 各管一块),非子控件。
- 文本用 GDI
DrawTextW(经Graphics::GetHDC()/ReleaseHDC()与 GDI+ 混用),保证清晰且定位精确; 切勿把自定义函数命名为DrawText/DrawTextEx/GetObject等与 winuser 宏冲突的名字(本项目用UiText/UiFillRoundRect前缀)。 - 双缓冲:
Application::EnsureBackBuffer,WM_SIZE/WM_DPICHANGED 时释放重建。 - 网格数量变化会在
Render()中触发Relayout()(影响滚动条与绘图区宽度)。
架构(数据与视图配置分离)
Utilities/Inc/DataModel.hpp:DataSet(多文件只读数据层,DataFile/SignalData)+TabConfig(每标签页独立配置)。SignalRef{fileIndex,signalIndex}是全局信号标识;不要再引入单文件假设。- 显示外观属于信号本身、可见性属于标签页(关键分工,勿混):
SignalStyle{color,customColor,opacity,lineWidth,mode}存在SignalData::style(DataSet 内), 是全局共享的 → 任一视图里改颜色/线宽/透明度/样式,其它视图自动一致。 访问一律走DataSet::Style(ref)(const/非 const 两个重载)。TabConfig只存m_visible(vector<vector<bool>>)与Lane/折叠/选中/视图状态。 可见性接口仍为TabConfig::IsVisible/SetVisible/ToggleVisible/SetFileVisible/SetAllVisible/SoloSignal。- 不要再给
SignalStyle加visible字段,也不要恢复TabConfig::Style()(已删除)。 - 组件侧辅助:
SignalPanel::StyleOf(ref)(内部取m_dataSet(_Writable)->Style);PlotView::ResolvedSignal{data,style,file,visible},visible来自m_tab->IsVisible(ref)。 - 新增信号的默认外观在
DataSet::AddFromTable里按全局信号序号取调色板色(PaletteColor已删除,改内联)。
- 可见性改动必须走上述
Set*接口——它们内部会SyncLanes(), 按LaneMode(PER_SIGNAL/MERGED/CUSTOM)重建网格,这正是"取消勾选后空网格消失"的保证。 RebuildLanes(可见性表, lanes, mode)的入参已从styles改为m_visible。- 信号"选中"属于
DataSet(跨视图共享),不属于TabConfig:DataSet::Selected()/SetSelected(ref)、ValidateSelection()、RemapSelectionAfterFileRemoval()。TabConfig::m_selected、TabConfig::Style()、SignalStyle::visible均已删除,不要恢复。 组件侧入口:PlotView::SelectedRef()/SelectSignal()、SignalPanel::SelectedRef()/SelectSignal();Application::SetActiveTab会把左面板滚动到m_dataSet.Selected()。 → "视图 1 里选中信号,切到视图 2 仍是选中"就靠这条(不要在 TabConfig 里重建选中态)。 - 文件增删顺序:各标签页
RemapAfterFileRemoval(idx)→DataSet::RemoveFile(idx)→ 各标签页SyncToDataSet()。 Application::SynchronizeView(force)在Render()开头统一收敛:检测可见数/网格数变化 → 无可见信号时tab.ViewInitialized()=false,有可见信号且未初始化时FitAll()→Relayout()。 这条复位是"清空网格/取消全部勾选后重新勾选第一个信号会重新自适应"的保证。- 组件按
(const DataSet*, TabConfig*)绑定;PlotView 内部保留m_localTab占位对象以避免空指针分支。
弹出菜单(UI 统一风格的关键)
- 本项目禁止使用
CreatePopupMenu/TrackPopupMenu/AppendMenuW(系统菜单风格与主题不符)。 - 统一用
Utilities/Inc/PopupMenu.hpp的PopupMenu:不建窗口,作为最后一层直接画进后备缓冲区。 - 用法:宿主调用
m_menu.Open(MenuScope, bounds, x, y, items)(x/y 为客户区坐标)→ 之后每帧m_menu.Paint(g);HandleMessage开头在m_menu.IsOpen()时拦截并路由鼠标/键盘(Esc/↑/↓/Enter),失焦或菜单外释放自动关闭。 - 菜单项支持
NORMAL/SEPARATOR/HEADER/SWATCHES/EDIT;选择结果经MenuResult{scope,id,swatchIndex}派发回来源组件 (信号面板命令 ID 见SignalPanel.hpp的MenuCommand)。 - 文本列统一为
kPadH + kItemPad(kPadH=5、kItemPad=8,匿名命名空间常量): HEADER / NORMAL / EDIT 三类在BuildLayout(宽度)与Paint(x)里都用它。kRightPad已删除;不要再把选中标记列放在左侧——那会把整份菜单的文本左边距整体推右。 - 勾选对勾(
item.checked)画在条目"右侧"的标记列(kMarkerColumn=18,右侧再留kItemPad):markerRight = rect.X + rect.Width - kItemPad*scale,对勾中心markerRight - kMarkerColumn*scale*0.5。 普通项文本宽度必须预留kMarkerColumn + kMarkerGap(4),否则打勾时文字会与对勾重叠。 - EDIT 行的输入框靠右对齐到条目内容区右边界(
rect.X + rect.Width - kItemPad*scale - width), 宽度由NaturalEditFieldWidth(item)计算(排版与绘制共用同一公式),并在空间不足时给左侧 说明文字保留自然宽度(避免菜单被kMaxWidth夹取后标签被省略成 "…")。 分隔线/色块行的左右内缩同样用kItemPad(色块起点 =kPadH + kItemPad)。 - 易漏点:
Open()之后必须InvalidateRect,否则菜单不会显示(曾在标签栏右键菜单上踩过)。
样式编辑区(SignalPanel 下半部分)
- 固定四行:颜色 / 样式 / 透明度 / 线宽(线宽在最下);行高 26、行距 10、上下内边距 12(逻辑像素),
高度由
DesiredStyleHeight()恒定 → 面板缩放时布局不会散架(勿再引入会改高度的内联展开区)。 - 统一内容列 + 拉伸上下限(
LayoutStyleControls):四行共用同一m_styleContentRect,宽度 =min(可用宽度, 260)(260 为逻辑像素上限,拉宽面板时控件不再延伸);标签列 + 内容列整体靠左对齐 (groupLeft = m_styleRect.X + padH,不要再取"居中",拉宽面板时控件不得向中间偏移)。 行标签一律用m_styleRowRects[i]的整行矩形绘制以获得垂直居中, 不要用某个控件的 Y 作标签 Y(会造成"颜色"字样与色块的高低差)。 - 颜色行的色块间距自适应:
gap = (contentWidth - size*(count+1)) / count并钳到 [4,10], 使该行与其它三行等宽;颜色行同样靠左(colorRowLeft = contentLeft),不再整行居中。 - 实测(面板 507px 宽时):颜色行与样式行均占 89–413px,右侧留 93px 空白 → 左对齐 + 上限同时成立。
- 极窄时数值文本自动省略
px单位(UiTextWidth(full) > rect.Width - 6*scale时只画数值)。 - 无标题行,选中信号即编辑目标。
- 颜色 = N 个预设色块 + 同尺寸「加号」方格(尺寸夹在 11–18 逻辑像素,宽度不够时递减预设数量)。
加号方格弹
ColorPickerPopup(浮动 RGB 面板,与 PopupMenu 同为最上层叠加绘制)。 方格始终填充记忆的自定义色(SignalStyle::customColor),自动选浅色/深色加号图标(IsDarkColor,亮度阈值 140)。 - 自定义色与预设色的"谁高亮"规则(勿改回):
customActive = (style->color == style->customColor) && 当前颜色不属于任何可见预设色。 必须加上后半条:否则自定义色恰好等于某个预设色时,点那个预设色块会把高亮挪到自定义色块上 (用户报告的"点预设色块 1 直接跳到自定义色块")。两个色块同时代表同一颜色时预设色优先。 customColor初值 = 调色板中"本信号预设色向后递进一格"的颜色 (palette[(slot + 1) % count],slot = 全局信号序号 % 调色板数)。 原先是customColor = color(与自身预设色相同)→ 那正是上述缺陷的根源。 因此同一文件里信号 N 的自定义色就是信号 N+1 的预设色,这是刻意的"向后递进"排布。- 透明度与线宽都是滑块(几何完全一致以保证纵向宽度对齐):
- 透明度 =
SignalStyle::opacity(0-1,默认 1)。 - 线宽 =
style->lineWidth,滑块范围 0.5–10 px。
- 透明度 =
- 滑块统一走
PaintUtil::UiDrawSlider(g, track, ratio, fillColor, active, scale)(样式区的透明度/线宽、拾色器的 RGB 三处都用它;勿再各自实现)。 关键:填充宽度必须严格= ratio * track.Width,不得设max(track.Height, ...)的最小值—— 否则滑块在最小值时会在圆钮右侧留下一条填充残留(圆钮半径 3.8 只覆盖 ±3.8px,而最小填充宽 6px)。
内联文本输入(统一模型:TextEdit)
- 所有输入框共用一个编辑模型
Utilities/Inc/TextEdit.hpp(TextEdit),字段:m_editField/m_editRow+m_edit(SignalPanel)、m_editField+m_edit(ColorPickerPopup)、m_editTarget+m_edit(PlotView)、m_editIndex+m_edit(TabBar)。m_editText/m_editFresh已全部删除——不要再各自维护字符串缓冲。 - 能力:光标/选区(
m_caret+m_anchor)、EditFilter{ANY,NUMBER,HEX,BYTE}、限长、 Ctrl+A/C/X/V(粘贴按过滤器清洗)、Shift+方向键与 Home/End 选区、退格/删除清除选区、 代理对安全步进。Begin(text, selectAll=true, filter, maxLen)即"整段选中"。 - 渲染统一
PaintUtil::UiDrawEditField(g, rect, const TextEdit&, font, scale)(签名已从(text, fresh)改为const TextEdit&):选区高亮 + 插入符, 文本超出框宽时水平滚动并保证插入符可见;整段选中时文字用textOnAccent, 局部选区只把选中片段重绘为textOnAccent。 - 键盘/字符路由仍在各组件
OnEditKey/OnEditChar:WM_CHAR的3/0x18/0x16/0x01= Ctrl+C/X/V/A; Shift 状态用GetKeyState(VK_SHIFT)(在WM_KEYDOWN里读)。 退格/删除只允许在OnEditKey(WM_KEYDOWN)里处理:WM_CHAR 也会送来\b, 两边都处理会导致"一次退格删除两个字符"。OnEditChar里一律character < 0x20 || == 0x7F直接 return false。 - 选区收拢语义(与常规输入框对齐,均已在 TextEdit 内实现):
MoveCaret(±1, extendSelection=false)且有选区 → 跳到选区左/右端并清除选区。MoveHome/MoveEnd(extendSelection=false)→ 无论插入符是否已在端点,都要收拢选区 (不能沿用 "已在端点就 return false" 的短路,否则整段选中后按 End 不会取消全选)。DeselectToEnd()→ 取消选区并把插入符移到末尾;由各组件CollapseEditSelectionToEnd()暴露, Application 在WM_RBUTTONDOWN最前面调用(编辑态下右键不再弹菜单/拖动)。- 鼠标编辑已补齐(一律走 TextEdit,勿再各自实现):
PlaceCaret(offset)/BeginDrag(offset)/UpdateDrag(offset)/EndDrag()/Dragging(); 组件侧统一入口OnEditMouseDown/Move/Up。共享几何与命中测试在PaintUtil:UiEditTextLeft(rect, edit, font, scale, float& innerWidth)、UiEditOffsetAt(font, text, localX)、UiEditFieldMouseDown/MouseMove。m_dragging必须在End()里清零, 否则下次进入编辑会带着上一轮的拖拽态。 - 输入框不得再设长度上限:一律传
maxLen = 0u(0 = 无限)。SignalPanel/TabBar/ColorPickerPopup/PlotView均已改为0u。 - 文本宽度测量用
UiMeasureTextWidth(HDC 无关),避免输入时横向抖动。
- 剪贴板:
Utf8Util::CopyTextToClipboard/PasteTextFromClipboard(后者把换行/制表符折叠为空格)。 宿主句柄:TabBar::SetHostWindow/PlotView::SetHostWindow/ColorPickerPopup::SetOwnerWindow。
内联文本输入的字段配置(沿用)
- 样式区用
StyleEditField{NONE,WIDTH,OPACITY,SIGNAL_NAME,FILE_NAME},入口SignalPanel::IsEditing()。 - 拾色器用
ColorPickerPopup::EditField{NONE,R,G,B,HEX},入口IsEditing(); RGB 用BYTE过滤 + 限长 3,色号用HEX+ 限长 7(接受带或不带#)。 - 样式区的线宽/透明度用
NUMBER(透明度提交时 /100 并钳到 0-1),名称类用ANY+ 限长 64/96。 - 提交:回车 / 点击框外 / 失焦;Esc 取消。
ColorPickerPopup::Close()会先EndEdit(true), 避免用户输入被静默丢弃。 - 颜色样块统一走
PaintUtil::UiDrawSwatch(rect, color, opacity, radius, scale): 按不透明度填充,且仅当opacity < 0.45时补一圈描边(UiSwatchNeedsOutline)。 预设色块、自定义方格、信号行色块、波形图例色块全部走它——可见时不画描边, 否则会出现"透明度 0% 完全不可见"或"满不透明度仍有边框"两类缺陷。阈值常量在 PaintUtil.cpp 的匿名命名空间。 DrawMode只有 LINE/SCATTER/LINE_SCATTER(STEP 已删除)。- 网格归属不在样式区,由每条信号行末尾的 G 徽标承载(点击弹菜单)。
绘图区(PlotView)
- 新加载信号的默认线宽是
1.0f(SignalStyle::lineWidth,在DataModel.hpp声明与DataModel.cpp::BuildDataFile的样式初始化两处,改的时候要一起改)。 - 工具栏按钮文案:「时间轴选区」「Y 轴选区」(原「时间轴缩放 / Y 轴缩放」已改名;
ToolbarAction::ZOOM_TIME/ZOOM_VALUE枚举名未改)。状态提示语同步为「…选区」。 - 散点半径随线宽线性增长:
radius = clamp(lineWidth * 1.5, 1.6, 12.0) * scale(常量kScatterRadiusScale/kMinScatterRadius/kMaxScatterRadius)。 不要再乘一个很小的系数并把上限设成 3–4px——那会让线宽超过 ~2px 后数据点不再变大。
大文件性能(百万级采样)
- 实测规模:
TestData/BIG_DATA.csv= 128 万行 × 11 列; 压测用TestData/VERY_BIG_DATA.CSV(用户提供)= 1.77 GB、约 2580 万行 × 6 列(5 信号) (按 72 字节/行采样估算;真实行数以程序内为准)。 - 顶点数就是绘制耗时(GDI+ 抗锯齿
DrawLines实测约 2.2µs/段,round join/cap 最贵)。 优化前固定kMaxPolylinePoints = 20000/条。 - 五项优化(都在现有单线程架构内):
- 像素列上下包络抽稀(
BuildPolyline的包络分支):预算由绘图区宽度决定 (plotWidth * kPolylinePointsPerPixel(2),下限kMinPolylinePoints(512),上限仍 20000)。 列边界必须用searchFrom单调推进(不要每列在全区间lower_bound,缓存缺失很贵)。 → MUST NOT 再用"限制每列采样点数"来降级(见第 4 条)。 - 粗粒度包络层(
SignalData::coarseMin/coarseMax/coarseStride,kCoarseStride = 256): 在ComputeStats里与 min/max 统计同一个循环内构建(不额外扫数据), 额外内存 ≈ 采样数据的 0.8%。包络读取"每列覆盖到的分组"后, 读取量从 O(可见采样数) 降到 O(像素列数 × 少量分组)。 条件:(i1 - i0) >= 2 * coarseStride时用分组层,否则直接扫原始采样(此时可见采样数很少)。 → VERY_BIG 全选时视图变化的包络构建 196ms → ≈0ms,且绘制结果与全量扫描一致。 语义差异:分组层只在"整组无有效值"时断线,密集视图中孤立 NaN 不再把波形切成两段 (原先会)——视觉上更干净,刻意保留这个行为。 - 折线顶点缓存
m_polylineCache(std::deque<PolylineCacheEntry>,上限 64 条,AcquirePolyline):键 = 构建折线实际读取的全部输入 (ref、file 指针、采样点数、ViewMin/ViewMax、plotRect.X/Width、lane.rect.Y/Height、lane.yMin/yMax)。 deque 保证 push_back 后已有元素引用有效(vector 会失效)。 - 不要改几何/顶点数来降级(已踩坑并回滚):曾经在
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,而观感几乎无差别。 - 散点必须合并成一条
GraphicsPath再FillPath,不要逐点FillEllipse: 实测 2500 点 63ms → 7ms(基准),程序内密集信号 51ms → 22ms,仍是抗锯齿圆形。 ⚠️ 必须紧跟SetFillMode(gd::FillModeWinding):GraphicsPath默认是FillModeAlternate(奇偶填充),重叠圆形的交叠区会被反向填充, 视觉上就是用户报告的"数据点重叠处出现白色缝隙/白边"。 主绘制与拖拽 ghost 的 path 都要设(.preflight/fill_test.cpp可复现对比)。 - 静态场景缓存(当前架构,务必按此维护):解决"大文件下任何悬停/选区拖拽都重绘波形"的卡顿。
- 缓存内容 = 仅绘图区
m_area(底色 + 全部网格 + 时间轴)。工具栏/标签栏/面板 每帧重绘(它们各自带悬停态,缓存会冻结悬停效果);Application::Render已不再有reuseScene分支(PlotView::CanReuseSceneCache已删除)。 - 任何一次
Paint只要键一致就贴图(不再限定拖拽模式):SceneCacheMatches()+BlitSceneCache()。 - 键
SceneCacheKey= viewMin/viewMax/laneScroll/laneCount/area*/scalehaloFile/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、**网格内悬停(含光晕)≈2832ms**; 时间轴/Y 轴选区拖拽 95105ms → **1922ms**;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%。 加入静态场景缓存后:**拖拽帧 134146ms → 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.6pxaxisLine,其余 2pxborderStrong, 常量kLaneDividerWidth);绘图区整体外框用axisLine1.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_*)当前顺序(用户明确要求):- HEADER
网格 N EDITY 轴最大值+ 值(PLOT_CMD_LANE_Y_MAX)EDITY 轴最小值+ 值(PLOT_CMD_LANE_Y_MIN)NORMAL选区 Y 轴自适应(PLOT_CMD_LANE_AUTO_VIEW,切换项,勾选 = 该网格开关开)NORMAL按网格时间轴展开(PLOT_CMD_LANE_FIT_TIME)- SEPARATOR
NORMAL选区 Y 轴自适应(全局)(PLOT_CMD_AUTO_FIT_ALL,切换项)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(全部自适应)。 ⚠️ 菜单纵向位置随锚点/内容变化,每次测试前先截图量一次,不要沿用旧值。
- HEADER
-
上下限就是菜单里的内联输入框(
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;- 放弃当前拖拽(
PlotView::CancelDrag()):复位m_dragMode/m_rubberRect/m_dragMoved/ m_dragSignal/m_dragTargetLane且不改动视图范围;调用方仍持有鼠标捕获, 松开左键时模式已是 NONE,选区不会落到时间轴/幅值轴上。 - 退出全部缩放模式(
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):ucrtstrtod实测 335ns/次, 手写路径 13ns/次(快 25 倍);这是大文件解析的真正瓶颈(几千万次调用)。- 非十进制写法(十六进制浮点、inf/nan、带尾部杂字符如
"12abc")原样交回ParseDoubleSlow, 接受语义与改造前完全一致。 - 有效位数 > 17 或指数超出 ±22 也回退 strtod;
mantissa超 2^53 时会比 strtod 多一次舍入, 相对误差 ≤1 ULP(实测 ≤2.3e-16),对显示(约 6 位有效数字)不可见。 - → 640MB 测试文件加载时间由约 40s 降到 2s 以内。
- 非十进制写法(十六进制浮点、inf/nan、带尾部杂字符如
- 实测内存:51MB 的 BIG_DATA 峰值约 126MB(数据本身 113MB); 640MB 的 HUGE_DATA 载入并全选后工作集约 566MB。旧路径(整文件缓冲 + 行优先 + 列拷贝) 峰值约为数据量的 2.8 倍。
- 验证脚本:
.preflight/mem.ps1(工作集/私有内存,从进程创建起就采样峰值)、.preflight/loadshot.ps1(加载过程中按间隔连拍,用于确认过渡动画)、.preflight/make_big_csv.ps1(用 Add-Type 内联 C# 快速生成大 CSV)。
应用图标(重要)
- 窗口类
hIcon/hIconSm与WM_SETICON必须用LoadAppIcon()从资源加载:LoadImageW(GetModuleHandleW(nullptr), MAKEINTRESOURCEW(IDI_APP_ICON), IMAGE_ICON, cx, cy, LR_DEFAULTCOLOR),cx/cy取SM_CXICON/SM_CYICON(大)与SM_CXSMICON/SM_CYSMICON(小)。 - 不要再用
LoadIconW(nullptr, IDI_APPLICATION)——那是系统默认图标, 会让标题栏、任务栏、Alt+Tab 缩略图全部显示成"通用应用",即用户报告的"缩略图/标题栏图标不正确"。 Resources/Inc/ResourceIds.h里的IDI_APP_ICON = 1;Application.cpp 需#include "ResourceIds.h"(Resources/Inc已在CXX_INCLUDES_PATHS中)。Resources/JustDataInspector.ico是多尺寸资源(256/64/48/32/16),系统会自动挑最接近的一帧。
文件分组(左侧列表)
- 分组标题格式
组X+ 绘制的竖线 +<分组名>(PaintFileRow): 序号fileIndex + 1用 accent 色,竖线用UiFillRect绘制而非|字符 (常量kGroupSeparatorGap/kGroupSeparatorWidth),随后是Title(),右侧为「N 项」与✕关闭按钮。 - 序号按当前位置连续编号 → 关闭某个分组后其余分组自动重新编号(无需维护额外状态)。
- 同一文件允许多次加载:
Application::LoadFile不再查重(DataSet::IndexOfFile已删除), 每次加载生成独立DataFile(各自持有完整采样副本),归入不同分组。 DataSet::RemoveFile用「依次前移 +pop_back+shrink_to_fit」而非erase, 使被移除文件的时间轴与信号缓冲区在本次调用内析构、容量一并归还。- 加载后不依赖原始文件:
CsvTable::ReadWholeFile在一处fread后立即fclose(所有失败分支也都关闭), 数据整份驻留内存;已实测删除源文件后仍可正常勾选与绘制波形。
标签页命名
Application::NextTabName()取当前未被占用的最小编号(而非自增计数器),关闭后可复用。
标签栏宽度(TabBar::Layout)
- 按加粗字体测量(
max(UiMeasureTextWidth(uiBold), UiMeasureTextWidth(ui))): 激活页用uiBold绘制,若按常规字体测量,长名称在激活时会被提前省略(预览过短)。 - 重命名时按
m_edit.Text()测量(而不是已提交的m_tabs[i].name)—— 否则标签宽度不会随输入增长,长名称编辑时只能看到滚动后的一小截(长内容绘制错误)。 上限用kEditingTabWidth=320(普通 200),并要求 Application 在WM_LBUTTONDBLCLK(进入编辑)与WM_CHAR(每次输入)后调用Relayout(),退出编辑时也调用。 - 标签总宽超出可用宽度时整体等比收缩(
shrink = available/total,下限kMinTabWidth), 并把m_addRect的右边界钳到区域内 → 「+」始终可见。
绘图区标尺与网格(可读性)
- 幅值刻度目标数 =
clamp(round(lane.rect.Height / (kYTickSpacing*m_scale)), 3, 8)(kYTickSpacing=28)。所有网格高度相同 → 刻度数量一致,避免"有的网格 1 条标签、有的 3 条"。 网格很矮时靠kYTickTargetMin=3保证仍有可读的密度(用户要求"区间过小时增加密度")。 - 标签行距 =
min(kYLabelMinGap*m_scale, lane.rect.Height/(yTickTarget+1))—— 网格矮时同步收紧,否则标签会被整片过滤掉。 - 主题色:
gridWeak=#DDD3B8、gridStrong=#C3B896(偏暖加深), 与网格边界的蓝灰(axisLine/borderStrong=#93A1A1)保持明显区分。 五等分辅助线用WithAlpha(gridWeak, kMinorLineAlpha=0x9A)。 - 时间轴列名补单位:若
timeColumnName不以)结尾则追加(s)→ 显示time (s)。
验证手段(无自动化测试)
- 优先用真实输入:
.preflight/real.ps1用SetCursorPos+mouse_event/SendInput产生真实鼠标键盘消息。 凡是与按下/松开、捕获、焦点、菜单相关的交互必须用它——PostMessage会掩盖这类缺陷 (徽标菜单"按住才显示、松手即消失"就是只在真实输入下复现的)。 PostMessage方案(.preflight/actions.ps1)仍可用于纯悬停/滚轮/拖拽等不涉及捕获语义的场景,速度更快。SendMessage方案(.preflight/act.ps1,会把窗口钉到 (0,0) 使坐标稳定)动作语法:move:x,y/click:x,y/rclick:x,y/dblclick:x,y/wheel:x,y,notches/key:VK/text:字符串/drag:x,y,dx,dy(按下→移动→松开)/draghold:x,y,dx,dy(按下→移动,不松开,用于截取拖拽中间态)/wait:ms。text:逐字符发 WM_CHAR——SendMessage绕过TranslateMessage,只发key:永远产生不了字符, 验证文本框输入必须用text:(real.ps1则用真正的SendInput扫描码,等价于真人键入)。act.ps1的坐标陷阱(重要):它的动作参数按截图坐标书写但直接把该数值当客户区坐标发给窗口, 而窗口被钉在 (0,0),所以窗口内控件需要客户区坐标 = 截图坐标 - 边框偏移(9,38)。 面板行高 27–30px 时这个 38px 偏移常"碰巧"仍落在目标行上(勾选信号就是这么蒙对的), 但绘图区/网格类的精确点击一定会差一行(曾在 y 差 38px 时命中了相邻网格)。 → 涉及绘图区/网格/曲线的交互,务必先减去(9,38)再传参。real.ps1的拖拽动作叫pressdrag:x,y,dx,dy(不是drag);drag:会静默落空。 另有down:x,y/up:x,y(单独发送按下/抬起,用于截取"按住不放"的中间态)与ctrlkey:c|a|v(Ctrl+组合键,验证 Ctrl+C 复制用)。real.ps1的动作写客户区坐标,act.ps1写截图坐标。real.ps1的key:只接受名称(enter/esc/back/tab),传数字 VK 不会发任何按键( 曾误用key:13导致"回车提交不生效"的假象);act.ps1的key:才用数字 VK。real.ps1的wheel:负增量需按 DWORD 回绕:[System.BitConverter]::ToUInt32([System.BitConverter]::GetBytes([int32]$raw), 0)(直接用[uint32]-120会抛"值对于 UInt32 太大或太小")。cursor.ps1 -Actions ... -ProbeX -ProbeY用GetCursorInfo+LoadCursorW比对光标形态, 传客户区坐标,返回ARROW/SIZEWE/SIZENS/...——验证"光标随模式变化"必须用它。crop.ps1的裁剪矩形超出图像边界时报内存不足(GDI+ 用该错误表示非法矩形)—— 先核对X+W <= 图宽、Y+H <= 图高(截图高度≈996,取 Y=786/H=200 就会越界)。- 坐标空间:截图为整窗(含标题栏+边框);
real.ps1按 客户区坐标 书写动作,内部用ClientToScreen(0,0)自动换算;actions.ps1按截图坐标书写。本机(125%)边框偏移(9,38)。 - 辅助脚本:
crop.ps1(裁剪放大)、pixel.ps1(取色)、vscan/scanline(像素游程定位控件)、cursor.ps1(用GetCursorInfo+LoadCursorW比对判断当前光标形态,验证光标类需求)、resize.ps1(自动MoveWindow多档缩放 + 可选-SelectPoints "x,y;…"先勾选信号, 用于验证缓存区域/布局在窗口尺寸变化后是否错位)。 - ⚠️ 测试脚本不一定会结束进程(
resize.ps1就会留着):重建前若报cannot open output file Build/Release/Target.exe: Permission denied, 先Get-Process Target | Stop-Process -Force再make。 - 空闲 CPU 测量:
.preflight/idle.ps1(本仓库脚本,.preflight 已被 gitignore)—— 自己启动进程、用 SendInput 执行click/wait动作建立场景,把鼠标停到不触发悬停的位置后 取Process.TotalProcessorTime的 5 秒增量并换算成“单核百分比”。 实测 10 网格 × 128 万采样时为空闲 0.00%。 - 内存测量:
.preflight/mem.ps1(工作集/私有内存;从进程创建起就采样才能拿到加载峰值)。 - 像素级 A/B 对比:
.preflight/hashregion.ps1 -In x.png -X -Y -W -H输出指定区域的 SHA256。 验证"某交互不应改变外观"时,对不受该交互影响的区域取哈希对比最可靠 (例:拖拽光标/框选拖拽不改变视图,故波形区域哈希应完全一致)。 - GDI+ 绘制基准脚本(
.preflight下,编译命令g++ -O2 -std=c++17 ... -static -lgdi32 -lgdiplus):scatter_bench.cpp(逐点 FillEllipse 63ms vs GraphicsPath+FillPath 7ms vs GDI Ellipse 3.6ms, 均针对 2500 个点)、line_bench.cpp(4800 点折线:AA round join 41ms、AA miter 31ms、 关抗锯齿 11ms、1 点/像素列 18ms、包络带 FillPath 22ms)。 结论:散点必须合并成一条 GraphicsPath;不要关抗锯齿(用户会立刻看出"变模糊")。 - 加载动画验证:
.preflight/loadshot.ps1(按间隔连拍 +InitialDelayMs控制起始时刻)。 要拍到遮罩必须把InitialDelayMs设小(约 250ms)且IntervalMs小于加载总时长。 - 帧耗时验证手法:在
Application::Render()开头与结尾各取一次QueryPerformanceCounter, 把耗时 append 到.preflight/frame.log;只测分量时在PaintLane内部打点。 ⚠️ 探针里不要每条信号/每帧fopen/fprintf/fclose(10 次文件 I/O 就能给单帧加几十毫秒, 会让"绘制很快"被误判成"绘制很慢")。先累加到静态变量、每 N 帧写一行。 ⚠️real.ps1的pressdrag只发 1 次 down + 10 次快速 move + 1 次 up, 实测拖拽期间的 paint 常被合并/推迟,日志里那几帧的m_dragMode往往是 NONE; 要测"拖拽中"的状态请用down+ 多个move(中间插wait)+ 不up。 - 工具栏按钮客户区坐标(125% 缩放、面板默认宽度、窗口 1725x996 实测,改按钮集合后需重测): 适应视图 (943,74) / 时间轴缩放 (1076,74) / Y 轴缩放 (1201,74) / 光标 (1292,74) / 合并网格 (1402,74) / 拆分网格 (1516,74) / 清空网格 (1619,74)。 换算公式:客户区 = 截图(图像)坐标 − (9,38)。搞错会点到隔壁按钮 (曾把「时间轴缩放」点成「适应视图」,导致“框选无效”的假象)。
- 验证"图层穿透"类缺陷:对不受目标变化影响的区域做像素哈希对比(如时间轴刻度行、工具栏视图范围文本), 拖动滑块前后哈希一致即证明未穿透;直接对比绘图区会因波形变色而误判。
- 定位控件的推荐流程:先用
crop.ps1放大目标区域读出真实像素位置 → 换算 → 点击 → 再crop.ps1核对。 - 面板几何随宽度变化(默认 366 逻辑像素 → 457px),行内右侧控件(G 徽标、✕)以及样式区所有控件的横坐标 都会随面板宽度而变;面板宽度一改就要重新量坐标,不要沿用旧值。
- 样式区颜色行实测坐标(125%、默认面板宽、窗口 1725x996):色块为 10 个预设 + 1 个加号方格,
间距 30px,第一个预设中心 截图 x≈99(客户区 x≈90),加号方格中心 截图 x≈399(客户区 ≈390),
行中心 截图 y≈820(客户区 y≈782)。
.preflight/scanline.ps1 -Y 820 -X0 70 -X1 420 -MinRun 3可复测。 - 信号列表复选框实测坐标:第一行复选框中心 客户区 (60, 206),行间距 ≈34.5px (点 (54,196) 之类的偏移会落到行主体 → 只选中、不勾选,这点容易误判)。
- 面板宽度上下限:260–400 逻辑像素(
kMinPanelWidth/kMaxPanelWidth),拖动分隔条会被钳制。 - 拾色器的滑块行坐标会随锚点翻转(在触发器上方或下方),定位前先截图确认。
- 复选框仅 15 逻辑像素见方且垂直居中,坐标必须取行中心附近,否则命中行主体(只选中、不勾选)。
- 坐标反复算不准时,临时在命中分支里
fopen/fprintf打点(输出 area/rect/part/点击点), 跑一次读日志即可定位,比反复猜坐标快得多;定位后务必删除该诊断代码。 - 不成立即为"空操作"的场景:单条可见信号下「新建网格」会把原网格回收,结果仍是 1 个网格 → 验证菜单项要用两条信号(2 网格 → 移回 G1 → 合并为 1 网格)才看得出效果。
- 新数据:
TestData/[2].CSV由.preflight/make_testdata.ps1生成(缩放+偏移+抽稀,多一列),用于多文件对比测试。 - 验证"无原始文件依赖":
.preflight/independence.ps1把数据拷到临时文件 → 启动程序 → 删除该文件 → 再勾选信号并截图;能正常出波形即证明加载后不再引用源文件。
Git 提交(本机陷阱)
- 本仓库已设
user.name=DeepSeek V4.1 Flash、user.email=<>(仅本地生效,勿动全局)。 - 提交信息用
-F 文件提交(多行-m会被 PowerShell 截断)。文件用无 BOM 的 UTF-8 写入: PowerShell 5.1 不支持Set-Content -Encoding utf8NoBOM(该值会静默失败导致提交流程中断), 必须用[System.IO.File]::WriteAllText($path, $text, (New-Object System.Text.UTF8Encoding($false)))。 git log在 PowerShell 里显示中文为乱码是显示假象:PowerShell 用控制台代码页解码 git 的 UTF-8 输出。 提交对象里其实是正确的 UTF-8。核对方法:cmd /c "git cat-file commit HEAD > raw.txt"再用[System.Text.Encoding]::UTF8.GetString(bytes)解码,看字节是否为228,191,174("修")而非230,183,135("淇")。 设置LC_ALL=C.UTF-8对git commit -F无效。- 不要用终端命令往文件里追加中文:
run_in_terminal里写的非 ASCII 字符会被二次编码, 落盘成乱码(实测给.gitignore追加# 测试数据变成# 娴嬭瘯鏁版嵁)。 给已有文件加中文注释/文案一律用文件编辑工具(edit/replace)而不是 PowerShell 命令。
历史重写(剔除 TestData,已完成)
- 目标终态:只有
main分支(master已删除)、全部历史不含TestData/、TestData/在.gitignore中、磁盘上 3 个文件完好。 - 做法:
$env:FILTER_BRANCH_SQUELCH_WARNING=1+git filter-branch --force --index-filter "git rm -r --cached --ignore-unmatch TestData" --prune-empty --tag-name-filter cat -- --all→ 删refs/original/*(git for-each-ref --format="%(refname)" refs/original/ | % { git update-ref -d $_ }) →git reflog expire --expire=now --all→git gc --prune=now→git branch main/git checkout main/git branch -D master。 - ⚠️ 最大的坑:
--index-filter虽然只改索引,但filter-branch收尾会做一次工作区更新 (等价git read-tree -u -m/checkout),把已从历史剔除的路径从磁盘一并删掉。 实测TestData/[1].CSV与[2].CSV在重写后从工作区消失。 → 重写前必须先把要保留的文件复制到仓库外目录,重写后Copy-Item回来并用Get-FileHash -Algorithm SHA256逐个校验(BIG_DATA.csv有 49MB,虽未被追踪也一并备份)。 git log --all --name-only -- <path>在refs/original/...还在时仍会列出该路径, 别据此判断"没删干净";先删refs/original再复查(此时应无任何输出)。git checkout/git branch的提示走 stderr,PowerShell 会渲染成NativeCommandError红字 ——那是正常输出(Switched to branch 'main'),不是失败。- 本次改动后的历史:15 个提交,
main指向12b3531(原b229781,因.gitignore注释修正 amend 过)。 - 仓库外备份
_TestData_backup已按用户确认删除(删除前核对过内容与TestData/逐一相同)。 - 当前
main:d6a8183(性能优化 + Esc 语义),之后45b44ea(网格菜单重构 + 异步加载 + 解析优化 + 图标修复), 再之后04e98ee(Esc 退出缩放模式 + 移除拖拽降级 + 粗粒度包络层 + 散点批量化), 再之后a3e638f(默认线宽 1.0px + 菜单文本左对齐 + 散点白边修复 + 拖拽静态场景缓存), 再之后dd48cdd(工具栏"时间轴选区/Y 轴选区" + 菜单对勾右置与边距 + 选区自适应勾选 + 静态场景缓存推广到全部交互),再之后本次(颜色预设/自定义高亮修复 + 选区 Y 轴自适应改为 切换开关(含全局开关与工具栏「Y 轴自适应」按钮)+ 菜单项改名「按网格时间轴展开」并移位)。 - 用户已在
TestData/放入VERY_BIG_DATA.CSV(1.77 GB,5 信号) 用于压测; 我生成的HUGE_DATA.csv已被用户清理(磁盘上只剩BIG_DATA.csv/VERY_BIG_DATA.CSV/[1].CSV/[2].CSV)。 - 后续提交:
eaf5b9c→ed13aa7→a566d98→cd6e0ed→b884e7e→5182bd8→82a2936→fe72afc(上下越界数据点三角 + 中心轴曲线/取值跟随原信号样式 + 拆分项信号数尾缀 + 信号筛选框 + 稠密区域拖拽性能修复)。
稠密波形绘制(fe72afc,重要性能约定)
- 采样密度超过像素分辨率时(
visibleCount > budget)必须走「包络带填充」而不是锯齿描边。 旧做法是每列 min/max 两个顶点交替,形成 2000+ 条近乎满幅的斜线段; GDI+ 抗锯齿光栅化成本 ∝ 覆盖像素数,实测 14.26ms/网格(5 网格 ≈70ms/帧,就是拖拽卡顿主因)。 现做法:逐列记录上/下包络(各外扩半个线宽,保证细波动也有可见粗细), 收段时拼成「上包络正向 + 下包络反向」的闭合多边形,一条GraphicsPath+FillPath绘制。 基准(1224 列):14.26ms → 1.69ms(8.4 倍),保留抗锯齿,静态与拖拽帧一致。 ⚠️ 必须FillModeWinding(包络自交,交替规则会挖洞)。PolylineCacheEntry::band/bandBreaks/bandMode存这两套数据;points(锯齿)仍保留给HitTestSignal用(它假设每段 x 单调,包络带的回折部分会破坏该假设)。 MOVE_SIGNAL 虚影同样要用包络带。 UiFillRect在面积 ≥kGdiFillAreaThreshold(20000px²) 且颜色不透明时走 GDIFillRect: 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、绘图区从 4787ms 降到 3.64.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/YvsedgeCenterPanelX/Y)。
边沿计数(中心轴)(5182bd8 新增)
⚠️ 本节已被"最新一轮"取代:
edgeCenterSmooth与CenterCurveFor/PaintCenterCurve/CenterValueAt均已删除。现在参数与曲线完全复用均值滤波(edgeCenterItems+MeanFilterCurveFor(X, N)), 面板也是逐条目三排布局。以下仅保留历史脉络(颜色派生、统计符号判定、缓存键思路仍然有效)。
Lane状态:edgeCenter / edgeCenterSmooth(默认5) / edgeCenterPanelOpen / edgeCenterPanelX/Y, 随RebuildLanes的LaneCarry按信号继承。菜单命令PLOT_CMD_LANE_EDGE_CENTER = 8。- 中心平均值曲线 = 每个数据点与左右各 N 点的均值(窗口 2N+1,边界按实际窗口,NaN 不参与且中心为 NaN 则不画)。
用滑动窗口求和(O(n),无前缀和内存):
while (windowLow < low) remove; while (windowHigh < high) add;缓存m_centerCurveCache(std::deque,键 = 信号指针 + N)→ 引用稳定。PaintCenterCurve用等距抽稀(平滑曲线无尖峰,不需要包络层);centerSmooth在PaintLane内取值, 并已加入SceneContentHash(否则开关/改 N 不会重绘)。 - 曲线颜色必须与波形区分:波形与曲线同色时会被完全淹没。现用
MixColor(signalColor, 黑/白, 0.58/0.62)(按IsDarkColor(m_theme->plotBg)选择方向)+ 线宽 +0.8。 - 统计:
EdgeCenterCountsFor比较signal[i] - center[i]的符号变化 (由负转非负 = 上升沿,由正转非正 = 下降沿);缓存键含 smooth + 时间区间。 - 纵向光标额外标注中心曲线取值:在
PaintCursor里用CenterValueAt取最近采样的曲线值, 画中 <值>胶囊(与曲线同色、alpha 0xB8)。取值胶囊避让已抽成PlotView::PlaceCursorLabel。 - 面板底部固定一行「平滑点数 N」:新增
PlotEditTarget::EDGE_CENTER_SMOOTH,EditFieldRect把它放到m_editFieldRect行内靠右;EndEdit夹在 0–1000000 并清空两个派生缓存。 - ⚠️ 校验脚本
.preflight/centerprobe.ps1(朴素 O(n·N) 实现,全区间与实现逐位一致);centerprobe2.ps1模拟实现的区间语义。光标区间计数会有 ±2 差异,那是界面上光标时间 显示值取整(6 位有效数字)造成的边界差,不是逻辑错误。
图例联动与场景缓存(b884e7e)
- ⚠️ 静态场景缓存会把图例的选中/悬停底色一起冻住:任何影响图例像素的状态都必须
要么进缓存指纹(
SceneContentHash,例如SelectedRef()),要么改成贴图后绘制。 - 选中态进指纹:
SelectedRef()已折叠进SceneContentHash, 否则在左侧信号面板点选信号后图例不会高亮(用户报告的"图例联动失效")。 - 悬停态不进指纹(会因每次鼠标移动都让 80ms 的整幅重绘失效):
新增
m_hoverLegend+PaintLegendHighlight(),在两条绘制路径中 (缓存贴图快路径与整幅重绘路径)都调用,用WithAlpha(accent,0x38)叠半高亮。 新增随鼠标变化的绘制必须同时加在这两条路径。 OnMouseMove里hoverLegend = LegendAtPoint(...)命中时不再赋给m_hoverSignal→ 悬停图例不画信号光晕、不弹“按住拖拽可移入其它网格”气泡,点选仍走SelectSignal。
光标形态(b884e7e 用户明确要求)
- 悬停信号不改变鼠标形状(
QueryCursor对HitTestSignal命中返回 nullptr → 箭头); 只有MOVE_SIGNAL拖拽中才是IDC_SIZEALL。
时间轴(b884e7e)
PaintTimeAxis在主刻度之间增画kMinorDivisions(=5)等分的短子刻度(3px,m_theme->axisLine),与主刻度重合处跳过。
边沿计数(b884e7e 重做判定规则)
- 判定 = 穿越横向光标的幅值
Lane::edgeValue:由下向上穿越 → 上升沿; 由上向下穿越 → 下降沿(不要再用信号幅值中点判定,那样拖动光标结果不会变)。 - 缓存键
EdgeCountEntry= 文件 + 信号 +edgeValue+hasRange+rangeMin/rangeMax; 拖横向光标、拖时间光标都会改变键并重算。缓存m_edgeCountCache是mutable,EdgeCountsFor为 const(纯缓存,便于从 const 的EdgePanelWidth/EdgePanelRect调用)。 - 采样范围:时间单调时二分(左端多取一个采样点以便计入跨边界边沿),否则按区间过滤。
- 面板宽度自适应:
EdgePanelWidth(laneIndex)用UiMeasureTextWidth(不含省略号) 实测标题(边沿计数+EdgeRangeSuffix())与每行“名称 / 上升沿 N 下降沿 M”的宽度, 取最大后再夹在绘图区内。绘制时不要再用UiEllipsize—— 统计数字必须完整显示。 - ✕ = 关闭该网格的边沿计数功能(
SetLaneEdgeCount(index,false)),面板与横向光标一起消失。 - 双光标联动:
EdgeRangeActive()(DOUBLE 模式)+EdgeRangeMin/Max()(始终min/max(CursorTime(), CursorTimeAlt()),因此左右交换后区间依然正确); 面板标题追加EdgeRangeSuffix()=(光标区间内)。
校验脚本(.preflight,已 gitignore)
.preflight/edgeprobe2.ps1 -Column N:全区间按阈值数组复算边沿数;.preflight/edgeprobe3.ps1 -T0 -T1:指定时间区间复算。二者都与界面逐位一致。 ⚠️ C# 内联代码里不要写中文(PowerShell 控制台编码会把列名变成乱码导致编译失败), 列索引用数字传入。.preflight/cursor.ps1新增rclick:x,y动作(打开右键菜单)。
PopupMenu 二级子菜单(cd6e0ed 新增)
MenuItemKind::SUBMENU+MenuItem::submenu(std::vector<MenuItem>自嵌套,C++17 合法)。- 悬停父项 →
BuildSubLayout(index)展开;移开到其它条目 →CloseSubmenu()。 - 子菜单位置:
left = m_rect.X + m_rect.Width - kSubOverlap,右侧空间不足则翻到左侧; 位置随RebuildLayout()一并重建。 ContainsPoint()必须把m_subRect算进来,否则子菜单内点击会被判为"点菜单外" → 关菜单。OnLButtonDown记录m_subPressedIndex,OnLButtonUp要求按下/抬起命中同一项才提交; 点击父项(SUBMENU)不下发命令,只保证展开。- 子菜单内的 EDIT 项绘制走
EditFieldRect(index)是按主菜单矩形算的(未支持子菜单 EDIT); 目前子菜单只有 NORMAL 项,不要往里放 EDIT。 - 键盘:Esc/← 先收起子菜单;→ 展开悬停项的子菜单;↑↓ 在子菜单内移动;Enter 提交。
实验性功能:边沿计数(cd6e0ed 新增)
- 开关与横向光标状态存在
Lane:edgeCount / edgeValue / edgeValueValid / edgePanelOpen / edgePanelX / edgePanelY;RebuildLanes用LaneCarry结构按信号继承(与fitYToSelection同一机制)。 菜单命令PLOT_CMD_LANE_EDGE_COUNT = 7,入口PlotView::ToggleLaneEdgeCount()。 - 绘制:
PaintEdgeCursor(g, lanes)——横线 + 两端尖端朝内的三角把手(同CursorColor(1)), 左端 Y 值胶囊(纵向居中于横线、靠网格左侧);不画数据点取值标注。 面板:标题栏 “GX” 徽标 + 边沿计数 + ✕;每个信号两行(名称用信号色 / 上升沿 N 下降沿 M)。 - 拖拽:
EDGE_MOVE(IDC_SIZENS)、EDGE_PANEL_MOVE(IDC_SIZEALL),面板限制在m_plotRect内。 ✕ 只收起面板(edgePanelOpen=false),横向光标保留。 - 边沿判定 = 穿越
(minValue+maxValue)/2中点,NaN 视为断点;结果缓存在m_edgeCountCache(按文件/信号指针),随InvalidatePolylineCache()清空。 - ⚠️ 重大陷阱:
PlotView::Paint有两个绘制叠加层的入口—— (a)SceneCacheMatches() && BlitSceneCache(g)的贴图快路径(末尾直接 return) (b) 整幅重绘路径。任何随鼠标变化的新内容必须同时加在两边, 否则只在缓存失效的那一帧可见(本次边沿计数就因此"点开关没反应")。
工具栏 toggle 状态(cd6e0ed 修复)
Toolbar::Layout()会m_buttons.clear()重建按钮;必须先用previousChecked按 action 继承checked,否则「光标」文本变化触发的Relayout()会把同帧刚设置的 「Y 轴自适应」「选区放大」勾选态清空(用户报告的"切光标模式时前面开关被取消")。
网格图例编号(cd6e0ed 新增)
PaintLaneLegend在条目左侧先画 “GX” 徽标(kLaneBadgeWidth=30 / kLaneBadgeHeight=17, 与 SignalPanel 的kBadgeWidth/kBadgeHeight完全一致),随后cursorX后移kLaneBadgeGap=8;图例底板从legendBand.X一直盖到最后一个条目。
增量差值显示(cd6e0ed 修改)
- 右侧光标取值文案:
值 (+0.0008)/值 (-0.134)——去掉逗号、用圆括号包裹, 正负号按delta显式给出。
光标/提示(本轮新增约定)
- 信号悬停提示必须"跟随鼠标":
PaintSignalInteraction用m_mouseX/m_mouseY定位气泡 (左 = mouseX+12scale、上 = mouseY+14scale,再夹在绘图区内与该网格内)。 ⚠️ 关键陷阱: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_mouseOverPlotInvalidatePlotRect()。规则:
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里把「中心轴」与「均值滤波」的取值胶囊抽成了局部 lambdapaintDerivedCapsule(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)。- 「构建为新信号」链路(跨层,注意分工):
PlotView::OnLButtonDown点按钮 → 只登记m_pendingMeanFilterLane/Radius+SetHint;Application::WM_LBUTTONDOWN在m_plot.OnLButtonDown返回 true 后调ApplyPendingMeanFilterBuild();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 与 0 是同一个颜色(红)→ 拖到色相条最下面时
FromHsv(1,…)得到红色, 下一帧ToHsv反推回来是h = 0→ 指示条弹回最上面。 - 拖到色域方块右下角之外时
V = 0→ 颜色变黑 →ToHsv(黑)的饱和度无定义(=0) → 选点弹回最左边("回到左下角")。
- 色相 1 与 0 是同一个颜色(红)→ 拖到色相条最下面时
- 修法:把"选择状态"与 RGB 分开保存 ——
m_sat / m_val / m_hue是权威值,ApplyGamut/ApplyHue只改它们再由FromHsv推出 RGB; 只有外部改色(RGB 滑块、十六进制输入)才用RefreshSelectionFromChannels()反向同步。CurrentHue()直接返回m_hue。越界一律Clamp01投影到边界 ("对鼠标离开区域后的位置做出响应")。 - 顺带统一圆角:色域方块 / 色相条 / 预览色块 / 色号框都用
kSwatchCorner = 3.0f * scale(此前方块与色条是borderStrong+ 2px);RGB 轨道高度 6 → 8。 - 色相指示器改为"白描边圆角长方条、底色 = 该位置的颜色";色域选点去掉外圈深色描边。
- 色相条用 6 段
LinearGradientBrush(色相→RGB 本就是分段线性,6 段即精确)。
网格 GX 编号拖拽重排
TabConfig::MoveLane(from, target):target 是插入位置(0..n);向后拖先--destination再判断"是否真的移动",最后m_laneMode = CUSTOM+RenameLanes()→ 所有以 GX 位置推导的界面(信号面板徽标、图例徽标、右键菜单、浮动面板)自动同步。- 拖拽:
PlotDragMode::LANE_REORDER+m_laneDragFrom/Target/MouseY/GrabOffset。LaneBadgeRect()统一算徽标矩形(PaintLaneLegend已改用,绘制与命中永远一致),LaneBadgeAtPoint()命中即拖;QueryCursor在徽标上返回IDC_SIZEALL。 判定顺序放在浮动面板之后(面板压在波形之上应优先)。 - 让位规则(容易写错):向下拖时让位的是
(from, target)区间(不含 target 本身), 向上拖时是[target, from);写成<= target会多抬起一条网格。 目标槽位高亮 =first + target*H - (target > from ? H : 0)。 - 拖动性能关键:不整幅重绘。静态场景缓存保持有效,
PaintLaneReorder把缓存里每条网格的 横向条带按位移BitBlt回去(几十微秒),被拖网格最后贴(压在最上层); 先把m_plotRect铺成绘图区底色让出空档,再画目标槽位高亮 + 被拖网格的强调轮廓与投影。 ⚠️ 底色填充必须用m_plotRect而不是m_area(UiFillRect的 GDI 快路径不受 GDI+ 裁剪约束, 用m_area会盖掉时间轴)。
均值滤波升级:均值点数 X + 阶数 N(多阶)+ 逐信号条目
- 算法:新增
ComputeMovingAverageIterated(values, radius, order, out)(Utilities 自由函数), 第 k 阶以上一阶为输入;order == 1与ComputeMovingAverage逐位一致(已断言)。 预览与「构建为新信号」共用它。 - 数据模型:
Lane::meanFilterRadius→std::vector<Lane::MeanFilterItem> meanFilterItems({ref, points(X), order(N)})+Find/Ensure/RemoveMeanFilterItem/MeanFilterEnabled。LaneCarry(重建网格继承)、SplitLane、RemapAfterSignalInsert、RemapAfterSignalRemoval都必须同步处理meanFilterItems的引用(否则会指错信号)。 启用时默认只勾选网格内第一个可见信号;面板高度随勾选数量动态变化。 - 面板:
EdgePanelLayout新增rowChecks/rowPoints/rowOrder/rowRefs;EdgePanelWidth必须保证2*(max(labelX,labelN) + 6 + fieldWidth) + 8放得下 (否则"均值点数 X"的 X 会被数值框压住 —— 实测踩过)。 - 编辑目标:
MEAN_FILTER_RADIUS→MEAN_FILTER_POINTS/MEAN_FILTER_ORDER, 用m_editMeanRef区分条目。 - 曲线缓存:新增
m_meanCurveCache(键 = 信号 + X + N),与中心轴的m_centerCurveCache分开。PaintCenterCurve拆成PaintDerivedCurve(g,resolved,lane,curve)+ 两个薄封装; 光标胶囊paintDerivedCapsule(points, order, 后缀, 是否均值)(中心轴传(smooth,0))。 SceneContentHash必须 mix 每个条目的 ref/X/N。
工具经验
- ⚠️ 编辑工具在 CRLF 文件里插入的行是 LF → 会混合行尾;提交前统一成 CRLF
(本仓库
core.autocrlf=true,工作区 CRLF / blob LF 是正常状态,git diff不会整文件重写)。 同样地,曾带 BOM 的文件会被编辑工具重新写回 BOM,提交前用 Python 去掉。 crop.ps1的X+W/Y+H必须严格小于图像宽高(1707x949),越界只报"内存不足"。- 重定向 stdout 是 UTF-16LE,读日志要用
utf-16-le(否则会误判为"没有 FAIL")。 - 新增测试
[T]色域拖拽钳制(含"下一帧不回弹")、[U]网格拖拽重排+重编号+让位位移、[V]多阶算法等价性(1 阶 == 单次;2 阶 == 链式;总变差随阶数下降)+ 逐信号条目/默认勾选/面板几何。 合计 196 项检查全部通过。 - 最新一轮新增
[W]:FormatByteSize单位自适应、LoadCsvTable进度字节单调且总量恒定、 GX 徽标悬停不变光标/拖拽用IDC_SIZENS、双光标读数小值在上、三读数同列对齐+三角、 中心轴默认勾选/X·N/三排布局/曲线与均值滤波同一缓存、横向光标读数让开把手与越界三角、 加载浮层渲染。合计 241 项检查全部通过。 - 测试脚本
.preflight/uitest/build.ps1链接需要-lole32 -loleaut32 -luuid(CLSIDFromString)。
派生曲线窗口缓存 + 浮动面板像素图层(本轮,性能重构)
场景:VERY_BIG_DATA(2580 万采样 × 5 信号)+ 5 网格全开「均值滤波 / 中心轴 / 弱波增强」,
拖动弱波增强滑块。瓶颈不是波形(关掉全部面板后拖拽仅 11ms),而是下面两处
(数据来自 .preflight/perf4 探针,任务结束前可删):
- 派生曲线绝不能整条重算:
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+/帧)。
- 对齐到粗网格(
- 浮动面板必须做成像素图层(15
27 个面板全画一遍实测 3370ms,贴图 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。
- 验证(
.preflight/uitest新增[AF],全套 374 项通过):- "纯贴图帧" vs "整幅重绘帧"在绘图区逐像素完全一致(0 差异);
- 同一状态下"贴图" vs "只作废面板图层后重画"逐像素一致(面板矩形内 0 个 >32 的像素);
- 连续两次重画逐像素一致(排除渲染非确定性);
PaintEdgeCursor实测:全画 62~70ms → 贴图 0.3ms。
- 实测汇总(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()与"仅样式变化"两个分支共用同一个paintOverlayslambda。 实测 36.7 → 11~12ms/步(其余是 2ms 贴图 + 单网格 4~6ms + overlays)。
- ⚠️ 三个坑(都实测踩过):
UiFillRect对大面积不透明矩形走 GDIFillRect,不受 GDI+ 裁剪约束 → 补丁里的底色 必须按"补丁矩形"填,绝不能传m_area/m_plotRect(否则整幅缓存被覆盖,实测差 40 万像素)。- 网格 rect 是浮点数,
floor/ceil会把补丁向外多算一行;那一行属于相邻网格 (例如它的分界线),于是边界上留下 1px 接缝(实测 y=209/y=309 各差 68/1224 像素)。 → 补丁必须向内对齐到整数(上界ceil、下界floor),边界外的行一律由缓存提供。 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)时 改用 GDIFillRect,而 GDI 文字/填充不受 GDI+SetClip约束 → 会画到裁剪区外。UiDrawEditField的全选高亮正好是"不透明 + 长文本时面积巨大"→ 双击进入编辑(全选)时 高亮向左溢出输入框(组名 / 标签页名 / 信号名都会)。修法:调用UiFillRect前把 highlight 与clipRect手工求交(保留快路径,同时保证不越界)。- 结论:凡是用
UiFillRect画"宽矩形"的地方,都不能假设 GDI+ 裁剪生效,需要自己夹取。
标签页重命名在编辑态点击别处不退出
Application::WM_LBUTTONDOWN只在m_tabBar.HitTest(x,y)时把点击转给TabBar, 点绘图区/面板/工具栏/分隔条时m_editIndex一直是 ≥0。- 修法:在
WM_LBUTTONDOWN里OnEditMouseDown之后加一段——if (m_tabBar.IsEditing())就OnEditKey(VK_RETURN, result)+OnTabBarResult(result)(必须走 OnTabBarResult, 否则新名字丢失)。
信号行 ✕ 删除按钮
- 新的
RowPart::REMOVE_SIGNAL;SignalRemoveRect(row)的位置 =rowRect.X + (kPadding + kDisclosureWidth) * m_scale, 尺寸kRemoveButtonSize(14) * m_scale,行内垂直居中 → 正好落在组复选框那一列(与组复选框"垂直对齐"), 且在信号自身复选框(缩进kIndent)左边,二者不重叠。125% 实测:X=[35,52],组复选框=[35,53.75],信号复选框=[52.5,71.25]。 HitRow里先判 X 再判复选框;QueryCursor返回IDC_HAND;hover 高亮用m_hoverPart == REMOVE_SIGNAL。 ⚠️QueryCursor因此从const改成非 const(内部调HitRow→EnsureRows)。- 删除协议:面板
PostMessageW(m_host, WM_APP + 2, fileIndex, signalIndex)→Application::RemoveSignal。最后一个信号 → 退化为RemoveFile(fileIndex)(不留空组)。 - 数据层:
DataSet::RemoveSignal(move-shift +pop_back+shrink_to_fit,保证缓冲区内即刻析构)+DataSet::RemapSelectionAfterSignalRemoval+TabConfig::RemapAfterSignalRemoval(删可见性列、 删网格内引用、同文件更大下标 -1、清空空网格)→ 再SyncTabsToDataSet()。 与插入信号一样,中间删除必须整体重映射所有标签页,否则可见性/网格会错位。
离屏自动化验证(重要手法,可复用)
用户要求"测试期间不要干扰我的鼠标/键盘/屏幕",因此放弃 act.ps1/real.ps1
(它们会 SetCursorPos / SetForegroundWindow / 把窗口钉到 (0,0)),改用离屏 harness:
.preflight/uitest/main.cpp:app.Initialize(hInstance, SW_HIDE)(窗口创建但从不显示)→ 直接app.HandleMessage(app.m_hwnd, msg, wparam, lParam)注入输入 →app.Render()后用BitBlt从app.m_backDc拷到Gdiplus::Bitmap存 PNG。 不显示窗口、不动鼠标、不抢焦点、不截屏。- 访问私有成员技巧:先 include 全部系统/标准头,再
#define private public/#define protected public, 再 include 项目头,最后#undef。 - 编译:
g++ <项目 flags> .preflight/uitest/main.cpp <Build/Release/*.o 去掉 Main.o> -static -o uitest.exe -lgdi32 -lgdiplus -lcomdlg32 -lpsapi -luser32 -lkernel32 -lole32 -loleaut32 -luuid(CLSIDFromString需要 ole32;GetProcessMemoryInfo需要 psapi)。 - 坑:
-fexec-charset=GBK下窄字符串里的非 ASCII 会编译失败(Illegal byte sequence)→ 输出纯 ASCII;Gdiplus::Bitmap无默认构造且不可赋值 → 用std::unique_ptr<Gdiplus::Bitmap>;GetPixel/GetWidth非 const → 参数用Bitmap&。 - 行的 rect / name rect 只有在真正 Render 过一次(布局跑完)之后才有效,之前
RowNameRect()返回 (0,0,0,0)。 - 双击组行(BODY)第一下会
ToggleCollapsed,会把组折叠(既有行为)→ 后续测试要么先展开,要么别用真实双击。 - 结果:31/31 通过;含"删信号后样本缓冲从 123.8MB→111.5MB、进程私有字节 139→127MB"这类内存释放断言, 以及"后面那条信号的可见性/网格被重映射到新下标、空出的下标不会被继承"。
- ⚠️ 离屏 harness 里注入的按下会走到宿主的
SetCapture路径:始终用成对的按下/抬起(或InjectPress+InjectRelease),不要只按不放;隐藏且非前台的窗口通常拿不到真实捕获,加上立即释放,实测对用户无影响。 - 后续新增的测试辅助:
InjectPress/InjectRelease/InjectPressDragRelease(验证"抬起才生效")、CountDiff/CountDiffColumns(两帧像素差异与差异列数)、CountNotColor。
非固定周期采样下包络渲染丢失竖向跃变(Problem_Data.csv,本轮修复)
- 症状:加载
TestData/Problem_Data.csv(1.28M 行、10 列、相邻采样间隔约 27µs 且不固定, 含 4444 处 100–277µs 空档),勾选.UD\SLC_1ms\rtb_BodyIAC_K40+0x2, 放大到主时间轴一大格 10ms 时波形出现大量"断开"。 - 关键前提(先确认走了哪条渲染路径):
BuildPolyline仅当visibleCount > budget(budget = plotWidth*2 = 2448)时走像素列包络路径,否则走逐点描边路径。 同一个"10ms 大格"区间内:跨度 68ms 时 samples≈2420 → 描边;跨度 74ms 时 samples≈2659 → 包络。 测这类问题必须先确认实际分支,否则会在描边路径上白折腾(第一轮就踩了这个坑)。 - 真正根因:包络按像素列聚合,列内只统计"落在本列内的采样点"。而采样间隔(27µs)
与像素列宽(≈60µs)同量级,一次跃变的两个端点会分别落到相邻两列里 —— 对每一列来说
列内采样点都在同一边,于是这段竖向折线在两侧包络中都不出现 → 整段被丢掉,看起来就是断口。
实测 1224 列中有 32 列(与逐点描边对照)完全没有波形像素。
⚠️ 空列(
i1 <= i0)只是次要原因(24 列);"非空列丢失跨列折线"才是主因。 - 修复:列包络里补上「跨越列边界的两段折线在边界处的插值」——
新增自由函数
PolylineValueAt(time, values, count, firstIndex, t, out), 取t0(线段 i0-1→i0)与t1(线段 i1-1→i1)处的取值并入 low/high。 折线在列内线性、极值必在采样点或列边界上,因此「列内采样点 + 两侧边界取值」= 该列包络的 精确解,相邻列边界取值相同 → 严丝合缝;同时顺带覆盖了空列(两侧边界取自同一线段)。 - 验证手法(推荐复用):
- 临时加
JDI_FORCE_LINE=1强制走逐点描边路径,作为"正确波形"的对照基准; .preflight/col_coverage.py <band.png> <line.png> X0 Y0 X1 Y1:按像素列统计b > r(波形是紫蓝色,背景/网格都是暖色 r >= b)的覆盖率,报告"只被描边画出、包络漏画的列数"。 修复前 32 列漏画,修复后 0 列(1224/1224 全覆盖)。.preflight/sim_band.py:用 Python 复刻列包络算法并打印逐列数据,专治这类算法错误 (本次正是靠它发现"跃变两端分属相邻列")。
- 临时加
- 复现脚本:
.preflight/repro_problem.ps1 -ThirdDx <第三次框选宽度> [-ForceLine] [-NoInterp], 自动勾选第 4 条信号 → 选区放大三次 → 截图。 该文件面板行复选框客户区 y ≈ 250/283/316/349/382(第 4 行 = K40;点 382 会点到 K10)。ThirdDx=900得跨度 74ms(10ms/大格)且正好走包络路径。 - 回归:VERY_BIG_DATA(稠密包络)与 BIG_DATA(稀疏描边 + 包络)渲染均正常。
边沿计数:时间光标拖动卡顿修复
- 症状:VERY_BIG_DATA 全选信号后拖动时间光标,未开实验性功能时很顺畅(≈13ms/帧), 一旦对某个网格开启「边沿计数」或「边沿计数(中心轴)」,拖动变卡(实测 24ms / 34ms 每帧)。
- 根因:
EdgeCountsFor/EdgeCenterCountsFor的缓存键包含时间区间(rangeMin/rangeMax), 而拖光标时区间每帧都变 → 每帧全部命中失败 → 对每条可见信号重扫整段区间 (亿级采样下就是几十毫秒)。且缓存是std::vector且无上限,每个新区间都 push 一条, 拖一会儿就积累几百条,查找也退化成线性扫描。 - 修复:缓存键改为「文件 + 信号 + 基准(阈值 edgeValue / 平滑半径 smooth)+ 是否限定区间」,
区间不进键;缓存项改为记录「上一次统计的采样下标区间 [begin, end) + 计数」:
区间完全一致 → 直接命中;与上一帧共享一个端点(拖光标时必然如此)→ 只扫描两端差集增量加减;
两端都变 / 首次 / 时间轴非单调 → 整段重算。缓存上限
kEdgeCountCacheLimit = 48。 - 关键正确性细节(已踩坑):
AccumulateEdgeCrossings(pairFirst, pairLast)统计采样对k ∈ [pairFirst, pairLast](每对为(k-1, k)),起点对的连续性必须由deltaAt(pairFirst-1)决定(先把它当 previous 再进循环)。最初实现从循环内部把 hasPrevious 置 false 起算, 等于漏掉起点那一对,导致增量结果比整段重算恒多 1 个上升沿。 → 这类增量算法必须用「与整段重算逐位比对」的临时探针验证(本轮做过,去掉探针前确认零 MISMATCH)。 - 实测(VERY_BIG_DATA、5 信号、双光标、网格 1 开功能、拖动时间光标): 中心轴边沿计数 33.8ms → 13.3ms;普通边沿计数 24.5ms → 11.3ms。 面板数值随拖动实时更新且与整段重算逐位一致;单光标(无区间)走全信号统计并被缓存。
- 注意:横向光标(阈值)拖动每次都会换基准 → 仍需整段重算,本轮未处理(用户未报告)。
验证脚本
.preflight/perf_edge.ps1 -Label X -CursorX <光标线客户区x> -SubY <子菜单项y>: 自动勾选 5 信号 → 双光标 → 右键网格 1 → 实验性功能子菜单 → 开启指定功能 → 反复拖动光标, 拖动前后各截图(su_X.png/PE_X_pre.png/PE_X.png)便于核对面板数值变化。- 该状态下实验性功能子菜单项客户区 y(1725x996、125%、5 信号、rclick(800,200)、hover(850,436)): 边沿计数 ≈ 429、边沿计数(中心轴) ≈ 464、均值滤波 ≈ 497,子菜单 x ≈ 1105 (父项行中心客户区 (841,436))。菜单位置随状态有 ±16px 漂移,改状态后重新截图量取。 双光标时光标线客户区 x ≈ 1080 / 1226(y 500 可抓)。
命名审查与垃圾代码清理(上一轮,纯重命名/删除,无行为变更)
- 核心事实:加载完成后全部采样都在内存里,程序此后与源文件再无关系。 因此凡名字暗示"仍在读写文件"的 API 都属于误导命名。
- 重命名:
SampleFileAt→SampleSignalAt(取的是内存信号在给定时刻的采样值)。DataSet::AddPreparedFile→AddDataFile(只是把构建好的内存分组追加进集合)。PlotView::InvalidatePolylineCache→InvalidateDerivedCaches(它同时清空折线/边沿统计/中心曲线/场景缓存与悬停引用,"Polyline" 名不副实)。PlotView::CursorColor(int which)→CursorColor()(两条光标同色,which从未使用)。UiEllipsize(g, text, font, max)→UiEllipsize(text, font, max)(纯测量,不绘制,Graphics&参数被忽略 → 属于误导参数)。- 删除
UiTextWidth(g, text, font):与UiMeasureTextWidth(font, text)完全等价(重复 API)。统一用后者。
(void)xxx;是本仓库的"垃圾信号",出现即代表参数是残留。本轮已全部清除:TabConfig可见性族(SetVisible/ToggleVisible/SetFileVisible/SetAllVisible/SoloSignal/SetMode/ SyncLanes/FileHasVisible/FileAllVisible)去掉从未使用的const DataSet&参数。 仅SyncToDataSet真正需要 DataSet,保留。PlotView::OnLButtonUp(int x, int y)→OnLButtonUp(int y);OnMouseWheel(int x, int y, int d, bool s)→OnMouseWheel(int delta, bool shift);PaintSignalHalo(g, ref, ...)去掉未用的ref。BuildDataFile里未使用的局部rowCount。
- 删除的死函数(声明+定义皆无人调用,均经编译验证):
DataSet::Clear、DataSet::AddFromTable、SignalColorCount、WideToUtf8、StartsWith、PlotView::ViewMinTime/ViewMaxTime、PlotView::EdgePanelRect/EdgePanelCloseRect/EdgeCenterPanelRect、PlotView::ScaleLaneRange、PlotView::BeginLaneValueEdit、PlotView::LaneContentHeight、SignalPanel::NeedsScrollBar/IsFilterEditing/FilterText/ContentHeight、PopupMenu::EditingItemId、TabBar::IsDragging、TextEdit::PlaceCaret。 - 删除的死字段:
CsvTable::sourceRowCount(与rowCount完全重复)、CsvTable::skippedRowCount(只写不读)。CsvTable::delimiter/fileBytes仍在用。 - 死代码审计脚本:
.preflight/deadaudit2.py(扫头文件声明 + 统计全仓名字(出现次数), 可快速复现"声明但无调用"的清单。注意调用形=2既可能是"声明+定义=死",也可能是 "头文件内联+1次调用=活",需人工确认。 - 验证(VERY_BIG_DATA):加载/勾选/筛选框(含"无匹配")/样式区线宽文字省略/选区放大(横向与纵向)/
双光标读数与 Δt/右键菜单(含 Y 轴数值与实验性功能子菜单)/合并与拆分网格/标签重命名省略
全部与改动前一致;
make clean; make all零警告。 - ⚠️
act.ps1的动作参数是 客户区坐标(窗口钉在 (0,0)),而截图是整窗 → 换算客户区 = 截图 − (9,38)。 标签栏在客户区 y≈28(截图 y≈66 已是工具栏「导入文件」按钮,误点会弹出文件对话框)。
本轮(2026-10-12,起源设备):图层架构缺陷修复 + 「弱峰增强」0~200%
A. 渲染:移除后台图层线程(根因),改为逐网格同步滚动
- 用户现象(
VERY_BIG_DATA全信号、选区放大后用中/左键拖动移动视界):严重绘制错误、波形抖动、前台绘制被拖慢。 - 根因(三个叠在一起):
- 后台线程改写共享渲染状态:
RenderLaneLayerRegion为了用宽窗口映射渲染,会临时改写m_area/m_plotRect/Tab().ViewMin()/ViewMax()/ 各网格矩形,完事再“恢复”。而 UI 线程的OnMouseMove(平移写视界、命中测试读网格矩形)无锁读/写这些状态 → 后台的“恢复”会把 用户刚做的平移弹回、命中测试也会读到错位的网格。 - 全局锚点 + 逐网格内容:
RollWideBuffer用一个m_layerAnchorMin给所有图层算 同一个位移,而各图层可能是在不同视界下渲染的(后台逐网格渲染、或拖拽中渲染) → 各网格被平移了不同的真实距离 → 网格之间时间错位、贴回的像素互相矛盾。 - 细条用错映射:滚动补细条时用“当前视界”的映射渲染,而旧像素是另一个映射
(
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 次覆盖。
- 注:旧代码在该自动化场景下只能概率性复现缺陷(后台线程会被前台长期锁住而饥饿), 因此主要证据是“结构不可能出错(没有线程再改写渲染状态)+ 居中图层逐像素精确”。
- 居中图层 vs 整幅重绘:5 个网格逐像素 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 个网格就会在一帧里做完整批重画 → 卡顿;而“结算”帧在拖拽期间也会触发,
于是按住鼠标不动时在“宽基底快路径”与“整幅重绘(拖拽 → 关抗锯齿)”之间每两帧振荡,
表现为“波形质量轮流变差 / 断线 / 周期性卡顿,松手才恢复”。
本轮改动:
RollWideBuffer()不再做任何整块重画:逐网格整数像素平移 + 补细条; 内容过期 / 锚点过远(|shift| >= bitmapWidth-4)的网格只QueueLaneRebuild(), 本帧仍按现有像素平移(位置照样跟随)。- 新增
RenderPendingLaneRebuilds(budget):每帧最多重画budget个网格(Paint传 1)。 队列非空时PostMessageW(kMessagePlotDerivedReady)请宿主再送一帧,直到补齐。 BlitWideBuffer()改为局部贴图:图层只覆盖“视界 ± 半屏”,视界落在图层之外时 只贴有像素的那一段(srcX/destOffset/blitWidth三个量把 BlitBlt 夹到可用区间), 其余留由底色填充 —— 即“未绘制的部分先暂时不管,下个循环就绪后再用新缓冲”。 旧行为是subX < -0.5 || subX + width > bitmapWidth + 0.5就continue→ 整个网格瞬间变空。- “结算”(整幅全质量精确帧)的触发条件收紧为三者同时成立:
内容+视界与上一条快路径帧相同(
viewStopped)且m_dragMode == NONE且LanesAllReadyForView()。拖拽期间绝不结算 → 不再有质量振荡与周期性整幅重绘。 新增LaneLayerReady(i)/LanesAllReadyForView()(结构 + 内容指纹 + 跨度全一致); 原LayersReadyForView()改名LayersStructurallyReady()(只看结构,用于“跨度/布局变了 必须整批重建一次”这一种情形)。 - 跨度 / 尺寸变化(缩放、布局变化、首帧)仍然走同步整批
RebuildWideBuffer(): 这是“一次性”代价(不是平移路径),不做预算分摊,否则会出现整屏空白的中间帧。
实测(离屏 .preflight/panperf,VERY_BIG_DATA 5 网格,深放大 ≈2ms/大格,每帧平移 60px):
- 平移 40 帧:中位 6.2ms / p90 7.2ms / max 7.6ms(旧实现会周期性出现数十~上百毫秒的尖峰)。
- 按住键且视界停在数据边界(视界不再变化)12 帧:中位 0.29ms / max 1.29ms —— 证明拖拽期间不再有整幅重绘(旧实现这里会每两帧穿插一次整幅重绘 + 关抗锯齿)。
B. 弱峰增强:改为逐信号参数(对齐均值滤波)
- 数据层:
Lane::WeakPeakItem从using WeakPeakItem = MeanFilterItem(只有ref有效) 改为独立结构体{ ref, levelPercent = 150, iterations = 1 }; 删除Lane::weakPeakLevel/Lane::weakPeakIterations(网格级)。 - 因
WeakPeakItem不再是MeanFilterItem,TabConfig::RemapAfterSignalRemoval()里原本 绑定到std::vector<Lane::MeanFilterItem>&的purgeRefslambda 改为泛型 lambda +std::decay_t。 PlotView:删除LaneWeakPeakLevel/LaneWeakPeakIterations/SetLaneWeakPeakLevel/SetLaneWeakPeakIterations、PlotDragMode::PEAK_LEVEL/PEAK_ITERATIONS、m_peakDragLane、EdgePanelLayout::slider/sliderIterations、WeakPeakSliderHit()与三个滑块常量。- 面板:弱峰增强与均值滤波完全同构(逐条目「名称 + 复选框」+ 第二排两个输入框
「增强等级 %」/「迭代次数 N」+ 底部「构建为新信号」);
EdgePanelLayout.itemHasParams统一了 MEAN/CENTER/PEAK 的参数行占位。新增PlotEditTarget::PEAK_LEVEL_PERCENT / PEAK_ITERATIONS, 在EndEdit里分别夹到 0–200 / 1–10 并清m_weakPeakCurveCache。 - 指纹与快照:
SceneContentHash混合每条WeakPeakItem的levelPercent + iterations;CollectSceneDerived的flatten增加valueOf参数(MEAN/CENTER 取points,PEAK 取levelPercent);PanelSlotKey混合每条目的等级/迭代(面板数字随输入实时刷新)。 - 预览曲线 / 光标胶囊 / 「构建为新信号」全部改为读取该信号自己的条目参数, 因此「预览」与「构建」逐位一致,且同一网格内不同信号可用不同强度。
C. 浮动面板:层序(z-order)与命中修复
问题(用户报告):两个面板重叠时,对上层面板的 ✕ / 标题栏操作会落到下层面板
(因为命中按固定的 kEdgePanelKinds 顺序遍历、先命中先 break),于是“上层 ✕ 关不掉 / 标题栏拖不动”。
修法:
- 新增层序
m_panelOrder(std::vector<PanelRef{laneIndex, kind}>,队尾 = 最上层,mutable)。 SyncPanelOrder():按现有顺序保留仍可见的面板,把新出现的面板追加到队尾(新面板永不被盖住)。TopPanelAtPoint(x, y, lane, kind):从队尾往前找第一个命中的面板。BringPanelToFront(lane, kind):点击面板(任意部位,含 ✕/标题/输入框)时把它移到队尾 = 置顶。- 绘制循环改为按
m_panelOrder自下而上绘制(与命中顺序、像素缓存级联一致);PanelGlobalKey()混合层序 → 层序变化时整层面板缓存作废并按新层序重画。 QueryCursor与OnMouseMove的“是否在面板上”判断也改用TopPanelAtPoint(与点击一致)。
D. 测试与脚本
uitest新增[AG]:层预算(RenderPendingLaneRebuilds(1)一帧只重画 1 个网格、剩余留在队列、 最终补齐且全部就绪)、局部贴图(把某层wideMin推到只覆盖视界左半 → 断言左侧被贴、 右侧保持哨兵色 = “未被绘制就不管”)、面板层序(上层面板 ✕ 命中上层、下层面板不受影响、 点击下层把它置顶、TopPanelAtPoint随之改变、新开面板在最上层)。uitest的[X]改为逐信号参数(含两个输入框的就地编辑:按下进入编辑、输入 175 回车写入 该信号自己的条目、999 被夹到 200、迭代次数写入、上下限夹取),并删除已不存在的滑块断言。- 新增离屏探针
.preflight/panperf(平移帧耗时 + 按住键静止帧耗时)。 - 验证:
make all零警告;uitest388/388 通过;panperfPASS;.preflight/layercheck保持“居中图层 vs 整幅重绘逐像素 0 差异 / 平移对齐 ≤1px / 0 次视界覆盖”。
本轮(2026-10-12,起源设备):弱峰增强改回逐信号滑块 + 「迭代次数 N」上限可编辑
用户要求:把上一轮改成“逐信号两个输入框”的参数输入方式,改回上一次提交之前的输入方式 (滑块),但每个信号各自一套滑块;并且「迭代次数 N」滑块右侧的数值可以点击, 整行(滑块 + 数值)变成一个输入框来修改该滑块的上限。
数据模型
Lane::WeakPeakItem新增int maxIterations = 10;(该信号迭代次数滑块的上限,用户可改)。- 上限参与
PanelSlotKey(决定圆钮位置 → 面板必须重画),但不进SceneContentHash(上限不影响网格内画出的曲线,因此不该让波形重画)。
面板输入方式(滑块的“上一次提交之前”形态,但逐信号)
EdgePanelLayout新增四个逐信号矩形数组(std::vector<gd::RectF>,与rowRefs同序):rowLevelTracks/rowLevelValues(增强等级滑块 + 右侧数值)、rowIterTracks/rowIterValues(迭代次数滑块 + 右侧数值)。 PEAK 面板的rowPoints/rowIterations(文本输入框)恒为空矩形(MEAN/CENTER 仍用它们)。- 行高:每个被勾选的信号 = 名称行(22)+ 两行滑块(各 26)= 74;未勾选只占名称行。
- 列宽算式集中在两个文件内辅助函数里,
EdgePanelWidth与EdgePanelLayoutFor共用, 避免两处漂移:WeakPeakLabelColumnWidth(uiSmall)、WeakPeakValueColumnWidth(monoSmall)(后者取"200%"与"10000"的较宽者,为可改大的上限留位宽)。 轨道最小宽度用恢复的kWeakPeakTrackMinWidth = 120。 - 恢复
WeakPeakSliderHit()(按轨道矩形上下各放宽 ≥8px 命中)与PlotDragMode::PEAK_LEVEL / PEAK_ITERATIONS、m_peakDragLane+ 新增m_peakDragRef(每个信号一套参数,拖动时必须记住是哪个信号)。 - 删除
PlotEditTarget::PEAK_LEVEL_PERCENT/PEAK_ITERATIONS(等级只能拖滑块;迭代次数只能拖滑块),新增PlotEditTarget::PEAK_ITERATIONS_MAX。
「迭代次数 N」上限的就地编辑(用户明确要求)
- 触发:点击迭代次数滑块右侧的数值文本(
rowIterValues[i])。 - 输入框范围:整条滑块行 —— 从轨道左端到数值文本右端
(
rowRect = {track.X, value.Y, value.X + value.Width - track.X, value.Height}), 即“整个滑动条部分包括数字完整变为一个输入框”。左侧的「迭代次数 N」标签保留可见。 - 初始内容为空:
BeginEdit(PEAK_ITERATIONS_MAX, lane, L"", rowRect)之后 再用m_edit.Begin(L"", false, EditFilter::BYTE, 0u)覆盖过滤器 (BeginEdit默认NUMBER,允许负号/小数点)。 - 灰色提示:
PaintEditField()里当m_editTarget == PEAK_ITERATIONS_MAX && m_edit.Text().empty()时用UiTextAtClipped画当前上限(m_peakEditCurrentMax,进入编辑时记录、EndEdit清零); 提示文本比插入符右移11 * scale,避免插入符压在数字上。 该输入框在paintOverlays()里与 Y 轴上下限输入框一起绘制(在面板之上)。 - 提交:回车 / 点击别处(沿用既有
EndEdit路径)。规则:EditFilter::BYTE= 只允许数字(负号、小数点、字母根本无法键入,粘贴同样被清洗);parsedOk且1 ≤ value ≤ kWeakPeakIterationsMaxLimit(=1000)→ 生效为新上限; 否则(空内容 /0/ 超出范围)→ 保持原上限不变;- 生效后若
iterations > maxIterations→ 自动替换为新上限(并清m_weakPeakCurveCache)。
- 滑块量程:
1 … maxIterations;比例 =(iterations-1)/(maxIterations-1)(max≤1时取 0)。 - 「构建为新信号」按该信号自己的量程夹取迭代次数。
验证(全部离屏,不占用鼠标/键盘/屏幕)
uitest[X]重写为:逐信号滑块存在性/层序/列宽/面板内、等级滑块拖动(中间=100%、 右端=200%)、迭代滑块拖动(左端=1、右端=当前上限 10)、 点击数值进入PEAK_ITERATIONS_MAX编辑(空内容 + 提示值 10 + 输入框横跨整行、 空内容回车不改、-/字母被拒、输入 40 生效、右端变为 40、下调到 4 时当前值被夹到 4、 输入 0 被拒、点击别处也能提交),并保存编辑态截图X_peak_max_edit.png供目视核对。- ⚠️ 测试坐标坑:
UiPointInRect右边界为开区间,按压“轨道最右端”的取整坐标可能落在 命中区之外 → 测试里按lround(X+W) - 1;而拖拽可以超出(UiSliderRatioFromX会夹到 1)。 make all零警告;uitest409/409 通过;panperfPASS;layercheck保持“居中图层逐像素 0 差异 / 平移对齐 ≤1px / 0 次视界覆盖”。
本轮(2026-10-12,起源设备):斜向自由移动视界退化 + 预览曲线抗锯齿 + 预览曲线闪烁
用户报告(原文要点):以 VERY_BIG_DATA.CSV 为例,全信号显示 + 时间轴方向选区放大(主时间轴约 2ms 一大格)后,
用鼠标中键/左键斜向拖拽自由移动视界仍然卡顿,且"这种不规则的视界自由移动会导致性能问题和绘制波形质量下降、错误绘制";
同时"实验性功能的预览曲线显示质量不如普通信号,似乎没有抗锯齿";"拖动弱峰增强滑块时预览曲线消失一会儿又画出来(跳帧闪烁)"。
A. 斜向拖拽的根因(为什么"纯水平"测不出来)
ShiftLaneRange(lane, dy)(拖拽的纵向分量 → 网格幅值平移)每帧都会修改该网格的 visual.yMin/yMax,而:
SceneGeometryHash()原本 =SceneContentHash(false),包含每个网格的yMin/yMax→ 幅值一变,"场景几何变了", 于是每一帧都判定宽基底不可用 → 退化到整幅重绘,并因拖拽置m_fastFrame = true→ 整幅 + 关抗锯齿(质量下降 + 卡顿)。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零警告;uitest420/420 通过。
F. 教训(写进 §11.2 硬约束)
- 网格幅值范围(
yMin/yMax)不得进入"几何/内容"指纹(那会让"平移的纵向分量"每帧毁掉快路径); 它只属于该网格自己的图层指纹。 - 图层指纹必须逐网格:任何"一个网格的变化使全部网格作废"的全局指纹都会把"一帧只补 1 个"的预算变成永久不一致。
- 待重画网格要在贴图之前补,否则用户看到的是"比鼠标慢半拍"的旧像素。
- 异步发布的派生曲线必须有"参数无关"的兜底(按信号 + 种类取最新),否则拖参数滑块必然闪烁。
- 交互优化只能降分辨率(顶点数),不能关抗锯齿:基底图层永远是全质量抗锯齿,叠加层一旦关了就会"看着更粗糙"。
本轮(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. 验证(全部离屏,不占用鼠标/键盘/屏幕)
uitest432/432:[AH]更新为"幅值平移不再让图层失效(只改贴图偏移)、幅值跨度变化仍然失效",并新增「纯竖直拖拽 8 帧 0 次图层重画」;新增[AI]:贴边读数抓取后光标落在鼠标处(误差 0.0px)、中键平移 1.5 屏期间 0 帧出界且光标确实跟手。panperf(VERY_BIG_DATA、5 网格、≈12ms 一大格):竖直拖拽中位 **3.85.0ms**、40 帧仅 1 次图层重画;水平 5.76.5ms(与修复前持平);对角 1113ms(略增:补条要按 2 倍画布高渲染);整幅质量对比残差与修复前同量级(水平 77k81k ↔ 修复后 77k81k;对角仅被拖动的那一个网格增加,属同一类 ≤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零警告。