Compare commits

...
7 Commits
Author SHA1 Message Date
LuChiChick 30e9d65c30 Merge pull request '实验性功能面板完善 + 预览曲线绘制管线性能优化 + UI主题配色 + 绘制效率及部分遗留问题修复' (#2) from UI_Refactor into main
Reviewed-on: #2
2026-11-02 11:59:00 +00:00
DeepSeek V4.1 Flash 0c320cf1b3 fix(plotview): 修复面板条目栏悬停时预览曲线高亮丢失 + 极端工况绘制管线提速
一、悬停高亮回归修复
- 根因:高亮画在预览曲线绘制路径里,而该路径在命中静态场景缓存时完全不会被调用
  (悬停态又不参与 SceneContentHash),因此缓存命中帧永远看不到高亮。
- 修法:拆出 PaintDerivedCurvesFor(hoverOnly) 两态,新增 PaintDerivedHoverHighlight,
  在 paintOverlays() 中紧跟 PaintHoverHighlight 以前景方式绘制(面板之下、光标之上)。
  高亮仍按面板种类(均值滤波/中心轴/弱峰调整)分别判定。

二、预览曲线绘制重写(单条 15ms → 1.7ms)
- 稠密区间改用与波形基底同一套「逐像素列 min/max 包络带」+ 一次 FillPath;
  旧抽稀折线在稠密视界下每段都是竖直满幅,GDI+ 描边代价正比于覆盖像素数
  (3200 顶点满幅锯齿抗锯齿 128ms / 无抗锯齿 31ms,包络带 1.9ms)。
- 列密度自适应:中等密度 2px/列,极密(>8 采样/像素)4px/列。
- 稀疏区间保持逐点折线;附带修复「等间隔抽样漏掉尖峰」。

三、预览曲线像素缓存层(Picture cache + pan roll)
- 45 条曲线画进带 alpha 的 32bpp DIB,正常帧只做一次 AlphaBlend(1~2ms)。
- 纯平移时整行 memmove 一个 dx 并只重画新露出的竖带(与宽缓冲区滚动同构),
  因此拖动视界的每帧代价与信号数无关。
- 内容指纹 = SceneContentHash(含样式/派生参数/网格几何) ⊕ 曲线发布序号;高亮不进指纹。

四、其他热点
- 光标取值胶囊不再逐帧重算并发布 45 条曲线(CurveSpanFor 新增 computeIfMissing),
  只取已覆盖的缓存曲线,未命中退到最新曲线或本帧不标注。
- 样式补丁路径不再同步重建网格图层(宽基底 2 倍宽 × 2 倍高,实测单次 2.2 秒),
  且开启派生功能时直接走整幅重绘(补丁会覆盖缓存中已烘入的曲线像素)。

实测(15 信号合并一条网格 + 全部实验性功能,1600x900 离屏探针):
拖动视界 264→5.9ms,双光标拖动 187→20.9ms,弱峰滑块 555→89ms,
改信号颜色 2661→200ms,悬停面板行 225→38ms,深放大拖动 207→23.8ms。
全屏 1920x1080:拖动视界 9.3ms。

验证:新增离屏探针 p27 8/8(悬停高亮在缓存命中帧可见、平移/缩放/改颜色后的叠加层
与「完全重画」参考帧逐像素一致 diff=0);p20 9/9、p21 9/9、p22 14/14、p23 8/8、p25 12/12;
make release / make debug 零 warning;Release 814,080 B。规则已同步到 Documents/agent_mem.md。
2026-11-02 18:47:21 +08:00
DeepSeek V4.1 Flash ea704b03ea fix(plotview): 边沿计数表格归位、统计面板悬停时持续更新、加载不再被统计阻塞、统计提速
1) 表格改造归位(上一轮误做在中心轴上)
- 「边沿计数」改为表格:表头(信号名/上升沿计数/下降沿计数)、每信号一行、
  行底色取信号配色、三列对齐、内容可滚动(Lane::edgeCountScroll)。
- 「中心轴边沿计数」恢复为可滚动分组列表(条目栏 + 复选框 + 展开后统计行 + 两行滑块),
  Lane::EnsureEdgeCenterItem 恢复默认展开。
- EdgePanelWidth / EdgePanelLayoutFor / PaintEdgeCursor 统一 listStyle / countTable 判定;
  EdgeCenterTableHeader 更名为 EdgeCountTableHeader。

2) 鼠标持续移动时统计面板不更新
- 根因:交互态(拖拽或 140ms 内移动)把统计数字整段从面板缓存键中跳过,
  只要鼠标不停移动就永远命中旧缓存。
- 修法:扫描推进/完成时自增 m_edgeScanRevision,Paint 开头转为 m_panelStatsVersion
  (非交互帧立即同步,交互帧按 kEdgeScanStatsRefreshFrames=4 节流),
  PanelSlotKey 对 COUNT/CENTER 仅混入该版本号(O(1),不再逐信号查询)。

3) 统计进行中无法加载文件
- 新增 PlotView::SetLoadingBusy:加载期间统计时间预算归零、跳过浮动面板绘制、
  不再自请求扫描帧;Application::LoadFileAsync 进出各设置一次。实测加载 22~31ms。

4) 统计提速
- 扫描预算由固定采样对改为 QPC 时间片(非交互 6ms / 交互 1.5ms,每块检查超时),
  采样对配额改为按未完成状态数公平分摊并把上限提到 8M(交互 2M)。
- 真实 MF4-Record.MF4(283 信号)全部统计状态在 1 帧内完成;悬停+移动帧中位 2~3ms。

验证:离屏探针 p20/p20c/p20d/p21/p22/p23/p25 全绿(新增 p25 12 项);
make release / make debug 零 warning;Release 808,960 B。共享规则已同步到 Documents/agent_mem.md。
2026-11-02 16:15:40 +08:00
DeepSeek V4.1 Flash 8de8a8fb85 预览曲线按面板高亮 + 统计数字不再卡在“统计中…” + 中心轴面板改表格 + 默认主题 Light + 标签页 DPI 修复 + 布局边界幽灵元素
一、预览曲线悬停高亮(只开弱峰+中心轴时无反应;三个全开时总亮均值曲线)
   根因:PaintLaneDerivedOverlays 里只有 MEAN 分支传 highlight,且悬停状态没有记录面板种类。
   修法:新增 m_meanRowHoverKind,高亮条件 = 悬停行 == 本行 && 面板种类 == 本曲线来源;
         条目栏强调色同样按种类门控;悬停种类进入面板缓存键。

二、统计数字松手后停在“统计中…”
   根因:交互期间面板缓存键被冻结(为拖动跟手),冻结期满后没有重绘请求(鼠标已停、
         统计也已完成),最后一次绘制仍是冻结帧。
   修法:记录 m_panelKeysFrozen 并公开 PanelRefreshPending();Render 末尾把它并入
         滚动动画定时器的武装条件 ⇒ 空闲后自动补一帧真实数字,随后停表。

三、「中心轴边沿计数」面板改为表格
   表头固定:信号名 / 上升沿计数 / 下降沿计数;每行底色 = 该信号配色(未勾选的行普通底色),
   三列与表头严格对齐,内容可滚动,单击行展开两行参数滑块。
   - EdgePanelLayout 新增 tableHeader / tableColumns[3];EdgePanelLayoutFor 新增 centerTable 分支;
   - 新增 EdgeCenterTableHeader() / EdgeCountCellText();EnsureEdgeCenterItem 默认 collapsed;
   - PanelSlotKey 的 CENTER 分支改为按 item 判断并混合计数(表格已没有 rowStats)。

四、默认主题改为 Light(Application、ThemeFor/ThemeName 默认分支、CurrentTheme 兜底)。

五、标签页文本在 DPI 切换后被省略
   根因:标签宽度恰好等于“内边距+文本+间距+关闭按钮”,无任何余量;TabItemWidget 缓存了
         Configure 时的 scale 并用它反推可用文本宽度,而 TabBar::SetScale 不重建控件树
         ⇒ 125%→100% 后可用宽度少约 4.5px ⇒ 吃掉末字。
   修法:SetScale 变化时重建控件树;并把文本可用矩形改由 ArrangeWidgets 统一下发
         (SetState 增加 textRect 参数),绘制侧不再自行反推。

六、布局边界里的幽灵元素
   - 新增 SignalPanel::HasStyleTarget();BuildBoxes 与 QueryCursor 在无样式目标时
     不再上报/响应色块、样式、调整、线宽、透明度等控件;
   - Paint 的“无网格”分支显式清空 m_legendHits / m_cursorLabelRects / m_cursorSpanRect /
     m_freeMarkerHits / m_panelSlots / m_panelOrder(关闭整个分组后不再残留边界框);
   - 浮动面板之上的滚动条 / 回到最上方 / 内联输入框改为按可见性上报几何。

验证(离屏探针,SW_HIDE + 合成消息,不占用鼠标键盘屏幕)
  .preflight/p21.exe 9/9   .preflight/p22.exe 14/14   .preflight/p23.exe 7/7
  .preflight/p20 系列(9/11/5)全部回归通过
  make release / make debug 零 warning;Release 813,568 B
2026-11-02 15:22:26 +08:00
DeepSeek V4.1 Flash 7a6fcd68e2 面板限高与滚动改造 + 边沿统计不再卡在“统计中…” + 组名折起真正收起
一、浮动面板(用户报告:下方空白、滚动时画到面板外、未改造的面板让一切交互卡顿)
1. ⭐ 根因:旧「逐条目」排布没有高度上限 —— 288 条信号时面板 13010px(绘图区 846px)
   ⇒ PanelRectInsideLayer 恒假 ⇒ 面板像素缓存一次都存不进去 ⇒ 每帧整块重画。
   现在 5 个面板全部限高 + 可滚动,旧的 itemized 布局/绘制/交互整体删除。
2. 无按钮面板(中心轴边沿计数)不再预留「按钮行 + pad」⇒ 底部空白 55px → 8.8px(1 个 pad)。
3. ⚠️ 统计文字改用 UiTextClipped:GDI 文本不受 GDI+ SetClip 约束,滚动时画到标题栏之外。
4. 「弱峰调整」改造为与均值滤波同款的分组列表(条目栏 + 右侧复选框 + 展开后的两行滑块
   + 复位按钮 + 底部「构建为新信号」);新增 Lane::weakPeakScroll / SetLaneWeakPeakScroll,
   WeakPeakItem 增加 collapsed。
5. 「边沿计数」保留两行排版,但内容区同样限高、可滚动(新增 Lane::edgeCountScroll)。
6. 鼠标指针:列表式面板的条目栏**只有已勾选**才显示手型(未勾选点击无效果,保持箭头);
   参数行控件只在参数行展开时给手型 / 左右箭头。
7. 内部标志 m_meanDragCenter / m_editMeanCenter(bool)改为 m_meanDragKind / m_editMeanKind
   (EdgePanelKind),以便 MEAN / CENTER / PEAK / COUNT 共用同一套滚动与滑块拖拽。

二、边沿统计“全部统计中…且永不更新”(288 信号合并到同一网格)
1. ⭐ 根因:累积状态上限固定 64 < 信号数 288 ⇒ 新建状态总淘汰未完成的 ⇒ 没有任何状态能扫完。
   新增 EdgeScanStateLimit() = max(64, 全部信号数 × 2 + 16)。
2. 去掉每状态 60000 的固定配额下限(状态一多就把整帧预算耗光),改为 budget / 未完成状态数;
   分母只数未完成状态,短信号扫完后立刻把预算让给长信号。

三、组名折起后“三角已收起但信号行仍在、仍可点选”
1. ⭐ 根因:RequestEdgeScanFrame() 在每个未完成状态里各 Post 一次(单帧最多 288 次),
   把优先级最低的 WM_TIMER 饿死 ⇒ AdvanceGroupAnimations 永不执行 ⇒ 折起动画永不结束。
   修法:每帧最多 Post 一次(m_edgeScanRequestFrame)。
2. 另一道保险:SignalPanel::EnsureRows() 开头推进一次动画 —— 动画进度只依赖挂钟时间,
   不依赖消息能否按时到达。

四、验证(离屏探针 .preflight/p20,全部 SW_HIDE + 合成消息,不占用鼠标/键盘/屏幕)
  p20.exe  9/9:面板限高且像素缓存真的写进去了、288 个统计全部 complete、中心轴面板底部留白、
               组名折起后行集合无该组信号、两组都折起后 signalRows == 0(真实消息 + WM_TIMER)。
  p20c.exe 11/11:弱峰面板限高/可滚动/缓存命中、展开与收起、滚动生效、条目栏手型规则、
               中心轴滚动时标题栏 0 像素变化(A/B 关掉裁剪 ⇒ 805px)、288 信号空闲帧 2.6ms。
  make release / make debug 零 warning。
2026-11-02 10:00:24 +08:00
DeepSeek-V4.1-Flash 334015c1a9 验证证据补记(第十九轮)
补充离屏探针段并回填文档:
* A2b:左键拖动视界(RUBBER_SELECT 叠加层每帧移动)0.86ms/帧,最坏 1.39ms。
* C 段新增「点击滑块数值 → 进入上限编辑」断言:m_editTarget == MEAN_POINTS_MAX、
  m_editMeanCenter == true、输入 100 后 maxPoints 变为 100。
* E 段:完整复现用户场景(VERY_BIG_DATA.CSV + MF4-Record.MF4 合并成一个网格 +
  自由标记含跟随光标开启),用真实 WM_LBUTTONDOWN/UP + WM_TIMER + WM_PAINT 点组名展开:
  109 帧、平均 2.75ms/帧、最坏 16.8ms、仅 1 帧超 16ms;每一帧三角状态与行集合一致;
  列表墨迹 7922 → 70037 → 7922。

bugcheck 73/73;panelcheck 71/71;markercheck 13/13;ringcheck 6/6;
make release / make debug 零 warning。
2026-11-02 03:39:34 +08:00
DeepSeek-V4.1-Flash 944c074ab3 288 信号卡顿根治 + 中心轴面板重做 + 标签页默认状态
一、卡顿真因(实测 29.8ms/帧 → 6.6ms/帧)
1. 中心轴面板高度没有上限:288 个信号时面板高 9996px(绘图区只有 1141px)
   ⇒ PanelRectInsideLayer 恒为假 ⇒ 面板像素缓存一次都存不进去 ⇒ 每帧全量重画。
2. PanelGlobalKey 混入鼠标驱动状态(m_hoverSignal / m_hoverLane / m_hoverCursor /
   CursorTime / EdgeRangeMin/Max)⇒ 鼠标一动键就变 ⇒ 所有面板每帧重画;
   PanelSlotKey 里还会为每个信号调一次 EdgeCenterCountsFor(单帧 288 次查询)。
3. 缓存命中路径的投影(与面板同大的半透明抗锯齿圆角矩形)每帧重画,GDI+ 实测 > 20ms。

修法:
* 新增 PlotView::InputIsInteractive()(拖拽中 / 鼠标 140ms 内移动过)。交互期间冻结
  全部鼠标驱动项与统计数字、跳过投影、统计预算降到 8 万(静止时 40 万)。
  m_meanRowHoverLane/Ref 例外 —— 它直接改面板像素且只在进出某行时变化。
* kEdgeScanPairsPerFrame 由 1,200,000 降为 400,000。
* 中心轴面板重做为可滚动列表 ⇒ 面板高度被绘图区夹住、缓存可用、只画可见行。

二、「中心轴边沿计数」面板重做(与「均值滤波」同风格)
条目栏为信号色叠底的可折叠栏 + 右端复选框;栏下同一行显示「上升沿 / 下降沿」;
再下面两行滑块(均值点数 / 迭代次数,标签不带 X/N),数值可点击输入上限、
右侧复位按钮;整个面板可滚动;未勾选时条目栏不可展开。
新增 Lane::edgeCenterScroll + SetLaneEdgeCenterScroll;交互复用 MEAN_* 拖拽模式 +
m_meanDragCenter / m_editMeanCenter 标志分派;光标形状与均值滤波一致。

三、标签页
* 唯一标签页点 ✕ 不再弹警告:直接 erase 后 NewTab(true),名字必然为「视图 1」。
* 新建标签页(含手动新建)默认:组全部折起、无选中信号(清空全局 Selected)、
  样式区无目标。

四、验证
.preflight/bugcheck.exe 59/59(A1 空闲 0.7ms、A2 平移 2.5ms、A3 双光标 2.4ms、
A4 自由标记 5.2ms、A5 中心轴拖动 6.6ms;B3 真实 WM_TIMER+WM_PAINT 路径;
B5 快速连点与动画反向;C 中心轴面板交互;D 标签页默认状态)
panelcheck 71/71、markercheck 13/13、ringcheck 6/6
make release / make debug 零 warning
2026-11-02 03:33:28 +08:00
19 changed files with 2996 additions and 945 deletions

No files matched your search

+1 -1
View File
@@ -521,7 +521,7 @@ namespace jdi
* 默认值是 **Solarised Light**:这是项目历史默认,用户 2026-10-29 明确要求
* 新增其它主题时"默认的主题色维持现状"。
*/
ThemeKind m_themeKind = ThemeKind::SOLARIZED_LIGHT;
ThemeKind m_themeKind = ThemeKind::LIGHT;
UiFonts m_fonts; /**< 界面字体。 */
PopupMenu m_menu; /**< 与主题一致的自绘弹出菜单。 */
ColorPickerPopup m_colorPicker; /**< 与主题一致的浮动拾色器。 */
+7 -1
View File
@@ -662,8 +662,14 @@ namespace jdi
// 信号列表与绘图区的动画(目标信号滚动 / 分组折起展开 / 网格滚动到可视区 /
//「回到最上方」按钮的淡入淡出):仅在动画进行中保持定时重绘。
// ⚠️ 另外还要覆盖「面板缓存键被冻结」的情形(用户 2026-11-02 报告:拖完横向光标 /
// 参数滑块并松开后,统计面板一直停在“统计中…”,直到鼠标移到面板上或做其它操作):
// 交互期间面板缓存键被冻结(复用旧像素),而**鼠标停下后不会再有重绘请求** ⇒
// 永远看不到最终数字。`PanelRefreshPending()` 为真时保持定时器,等
// `kEdgeScanInteractiveIdleMs` 过去、某个非交互帧把面板按最新数字重画一次。
if (m_panel.ScrollAnimationActive() || m_panel.GroupAnimationActive() ||
m_plot.LaneScrollAnimationActive() || m_plot.BackToTopAnimationActive())
m_plot.LaneScrollAnimationActive() || m_plot.BackToTopAnimationActive() ||
m_plot.PanelRefreshPending())
{
if (!m_scrollTimerActive)
{
@@ -22,6 +22,12 @@ namespace jdi
tab.SetAllCollapsed(true);
}
// ⚠️ **新标签页不存在任何被点选的信号**(用户 2026-11-02 要求):
// 选中状态是**全局共享**的(`DataSet::Selected()`),不清掉的话新建出来的标签页会
// 直接带着上一个标签页的选中信号 —— 样式编辑区于是有自己的“目标”,与新页“空白”
// 的预期不符。清空后样式区回到「未选择信号」状态,直到用户手动点选任意信号。
m_dataSet.SetSelected(SignalRef());
m_tabs.push_back(std::move(tab));
SetActiveTab(static_cast<int>(m_tabs.size()) - 1);
}
@@ -34,7 +40,14 @@ namespace jdi
}
if (m_tabs.size() <= 1)
{
SetStatusMessage(L"至少需要保留一个标签页。", true);
// ⚠️ **唯一标签页不弹警告**(用户 2026-11-02 要求):直接删掉它并新建一个默认的
// 「视图 1」—— 组全部折起、无选中信号、样式区无目标(由 `NewTab(true)` 保证)。
// ⚠️ `m_tabs` 清空后 `NextTabName()` 必然返回「视图 1」,编号自然从 1 重新开始。
// erase 与 `NewTab` 之间**不要**碰 `m_panel` / `m_plot`(它们的上下文仍指向
// 刚被释放的 `TabConfig`);`NewTab` 里的 `SetActiveTab` 会第一时间重新绑定。
m_tabs.erase(m_tabs.begin() + index);
m_activeTab = 0;
NewTab(true);
return;
}
@@ -101,6 +101,10 @@ namespace jdi
m_loadingProgress = 0.0f;
m_loadingBytesRead = 0;
m_loadingTotalBytes = 0;
// ⚠️ 告知绘图区“正在加载”:暂停边沿统计推进与统计面板绘制。
// 本函数的主循环会不断 `UpdateWindow` 触发整帧绘制,若面板继续扫统计 + 重绘上百行,
// 加载进度会被拖得很慢(用户报告“统计没算完就无法开始加载文件”)。
m_plot.SetLoadingBusy(true);
const std::size_t slashPos = path.find_last_of(L"\\/");
m_loadingText = (slashPos == std::wstring::npos) ? path : path.substr(slashPos + 1);
@@ -150,6 +154,7 @@ namespace jdi
m_loadingBytesRead = m_loadingTotalBytes;
worker.join();
m_loading = false;
m_plot.SetLoadingBusy(false);
if (state.cancelled.load(std::memory_order_relaxed))
{
+275 -1
View File
@@ -1097,6 +1097,27 @@ namespace jdi
*/
bool SetLaneMeanFilterScroll(int laneIndex, float scroll);
/**
* @brief 设置某网格「中心轴边沿计数」面板内容的滚动偏移(客户区像素,0 = 顶部)。
* @return 偏移真的发生变化返回 true。
*/
bool SetLaneEdgeCenterScroll(int laneIndex, float scroll);
/**
* @brief 设置某网格「弱峰调整」面板内容的滚动偏移(客户区像素,0 = 顶部)。
* @return 偏移真的发生变化返回 true。
*/
bool SetLaneWeakPeakScroll(int laneIndex, float scroll);
/**
* @brief 设置某网格「边沿计数」面板内容的滚动偏移(客户区像素,0 = 顶部)。
*
* 该面板仍是旧式「每信号两行」排版,但内容可以滚动:上百个信号合并到同一网格时
* 内容高达上万像素,不限高会让面板远超绘图区(面板像素缓存因此完全失效)。
* @return 偏移真的发生变化返回 true。
*/
bool SetLaneEdgeCountScroll(int laneIndex, float scroll);
/**
* @brief 切换某网格「自由标记」面板里某个信号分组的收起状态。
* @return 切换后为「已收起」返回 true。
@@ -1250,6 +1271,27 @@ namespace jdi
/** @brief 「回到最上方」按钮是否正在淡入/淡出。 */
bool BackToTopAnimationActive() const { return m_backToTopAnimating; }
/**
* @brief 上一条绘制帧是否**冻结了实验性功能面板的缓存键**(交互帧)。
*
* 冻结期间面板显示的是旧像素(统计数字停在“统计中…”),而鼠标停下后
* **不会再有重绘请求** ⇒ 面板永远停在旧数字。宿主应在为真时武装短周期定时器,
* 直到某个非交互帧把面板按最新数字重画一次(见 `Application::Render`)。
*/
bool PanelRefreshPending() const { return m_panelKeysFrozen; }
/**
* @brief 告知绘图区「宿主正在加载文件」。
*
* 加载期间主线程会不断 `UpdateWindow` 触发整帧绘制,而面板绘制含统计推进与上百行
* GDI+ 原语 ⇒ 加载进度被拖得很慢(用户报告"统计没算完就无法开始加载")。
* 加载遮罩会盖住整个界面,因此置真时直接跳过面板绘制与统计推进。
*
* @param [IN] busy true = 正在加载。
*/
void SetLoadingBusy(bool busy) { m_loadingBusy = busy; }
/**
* @brief 取消网格滚动动画(用户自己滚动网格时调用,避免动画与手动滚动抢滚动位置)。
*/
@@ -1709,6 +1751,32 @@ namespace jdi
*/
void PaintLaneDerivedOverlays(gd::Graphics& g, int laneIndex, const LaneVisual& lane) const;
/**
* @brief 绘制全部派生预览曲线(可只画悬停高亮的那一条)。
*
* `PaintLaneDerivedOverlays`(基底/缓存路径,不带高亮)与
* `PaintDerivedHoverHighlight`(前景高亮)共用本函数,保证两者的几何完全一致。
*
* ⚠️ **高亮绝不能画进静态场景缓存**(用户 2026-11-02 报告“悬停时预览曲线的高亮丢失”):
* 悬停状态不参与 `SceneContentHash`,命中场景缓存时叠加层根本不会被重画 ——
* 于是“悬停 → 高亮”永远看不到;而一旦参与哈希,又会退化成“每动一下鼠标就整幅重绘”。
* 正确做法:缓存里只画普通曲线,高亮那一帧在前景(贴图之后)单独叠加。
*
* @param [IN,OUT] g 绘图上下文。
* @param [IN] laneIndex 网格下标。
* @param [IN] lane 网格绘制信息。
* @param [IN] hoverOnly true = 只画 `m_meanRowHoverRef` 在 `m_meanRowHoverKind` 面板里的那条曲线
* (并带高亮);false = 画全部曲线且不带高亮。
*/
void PaintDerivedCurvesFor(gd::Graphics& g, int laneIndex, const LaneVisual& lane,
bool hoverOnly) const;
/**
* @brief 在**前景**叠加绘制“鼠标所在面板条目栏”对应的预览曲线高亮。
* @param [IN,OUT] g 绘图上下文。
*/
void PaintDerivedHoverHighlight(gd::Graphics& g) const;
/**
* @brief 绘制底部时间轴。
* @param [IN,OUT] g 绘图上下文。
@@ -1785,6 +1853,23 @@ namespace jdi
gd::RectF edit; /**< 面板底部编辑行(无编辑内容时宽度为 0)。 */
gd::RectF button; /**< 面板底部按钮(无按钮时宽度为 0)。 */
int rows = 0; /**< 统计行数(每条可见信号一行)。 */
/** @brief 表格的一列(左边界 + 宽度,客户区坐标)。 */
struct TableColumn
{
float left = 0.0f;
float width = 0.0f;
};
/**
* @brief 「中心轴边沿计数」表格的**表头行**(固定不滚动;内容区在它下面)。
*
* 该面板自 2026-11-02 起改为表格:表头三列 = 信号名 / 上升沿计数 / 下降沿计数,
* 下面每条**已勾选**的信号占一行,行底色 = 该信号的配色,三列与表头严格对齐。
* 单击行(非复选框)展开该信号自己的两行参数滑块。
*/
gd::RectF tableHeader;
/** @brief 表格三列的几何(`0` 信号名 / `1` 上升沿计数 / `2` 下降沿计数)。 */
std::array<TableColumn, 3> tableColumns{};
/**
* @brief 「逐条目」面板的矩形(MEAN / CENTER / PEAK 面板共用同一套排布)。
*
@@ -2273,13 +2358,22 @@ namespace jdi
* 而是交后台线程算,本帧先用已算好的旧曲线顶替(双缓冲),保证交互始终跟手。
* 仅供**预览绘制**传 true;需要精确取值的统计(中心轴边沿计数、取值胶囊)
* 传 false,走同步精确计算。
* @param [IN] computeIfMissing 缓存未命中时是否**进行计算**并发布新条目。
*
* ⚠️ 光标取值胶囊必须传 false(用户 2026-11-02 反馈“拖光标仍有明显卡顿”):
* 胶囊只要“光标所在的那两个采样”,而缓存键含请求窗口、窗口随光标每帧变化 ⇒
* 每帧都未命中 ⇒ 每帧同步计算 + 发布。15 信号 × 3 种曲线 = 每帧 45 次发布,
* 条目列表与线性搜索同步膨胀,实测拖动光标单帧 200ms(其中约 150ms 在这里)。
* 传 false 后:命中就取(绘制路径发布的窗口带保护带、覆盖整个可见区间,
* 正常情况必然命中),未命中则退到同 (信号, 种类) 的最新曲线,再无则本帧不标注。
* @return 曲线数组指针(覆盖 `[spanBegin, spanBegin + size())`);暂无可显示曲线时返回 nullptr。
*/
const std::vector<float>* CurveSpanFor(DerivedCurveKind kind, const SignalData& signal,
int points, int iterations, int amountPercent,
std::size_t begin, std::size_t end,
std::size_t& spanBegin,
bool allowAsyncCompute = false) const;
bool allowAsyncCompute = false,
bool computeIfMissing = true) const;
/**
* @brief 派生曲线缓存的键(决定一条派生曲线数值的全部输入)。
@@ -2807,12 +2901,31 @@ namespace jdi
SignalRef m_peakDragRef; /**< 正在拖动弱峰滑块的**信号**(每个信号一套参数)。 */
int m_meanDragLane = -1; /**< 正在拖动「均值滤波」参数滑块的面板所属网格。 */
SignalRef m_meanDragRef; /**< 正在拖动「均值滤波」参数滑块的信号(每信号一套参数)。 */
/**
* @brief 当前「列表式面板」参数滑块 / 滚动条拖拽的目标面板种类。
*
* 「均值滤波」「中心轴边沿计数」「弱峰调整」自 2026-11-02 起共用同一套**可滚动列表**
* (信号色叠底条目栏 + 右侧复选框 + 展开后的参数滑块)与同一套拖拽模式
*(`MEAN_POINTS` / `MEAN_ITERATIONS` / `MEAN_SCROLL`);本字段说明它们的写入目标是
* `Lane::meanFilterItems` / `edgeCenterItems` / `weakPeakItems` 中的哪一个。
* 「边沿计数」面板(旧式两行排版)也用它区分滚动目标。
*/
EdgePanelKind m_meanDragKind = EdgePanelKind::MEAN;
/**
* @brief 正在编辑「均值滤波」某个滑块上限时,输入框内灰色提示用的**当前上限值**。
*
* 与 `m_peakEditCurrentMax` 同一语义(输入框以空内容开始,空内容时显示当前上限)。
*/
int m_meanEditCurrentMax = 0;
/**
* @brief 正在编辑「滑块上限」的目标面板种类(均值滤波 / 中心轴边沿计数)。
*
* 与 `m_meanDragKind` 同一思路:两者共用 `MEAN_POINTS_MAX` / `MEAN_ITERATIONS_MAX`
* 两个编辑目标,本字段说明提交时要写到 `Lane::edgeCenterItems` 还是 `meanFilterItems`。
* 「弱峰调整」仍用自己的 `PEAK_ITERATIONS_MAX` 与 `m_peakEditCurrentMax`。
*/
EdgePanelKind m_editMeanKind = EdgePanelKind::MEAN;
/**
* @brief 正在编辑「迭代次数 N 上限」时,输入框内灰色提示用的**当前上限值**。
*
@@ -2867,6 +2980,32 @@ namespace jdi
/** 「弱峰调整」每行滑块右侧的复位控件是否处于悬停态(用于“进入/离开”时只提示一次)。 */
bool m_peakResetHover = false;
/**
* @brief 最近一次鼠标活动(移动 / 按下 / 滚轮)的时间戳(`GetTickCount64()`)。
*
* 用途:**边沿统计的每帧预算**要区分“交互中”和“静止”两种状态(见
* `kEdgeScanPairsPerFrameInteractive`)。交互期间压小预算保住跟手;鼠标停下
* `kEdgeScanInteractiveIdleMs` 之后自动恢复高预算,把统计快速补齐。
* 初值 0 ⇒ 启动后第一帧就算作“静止”(可以全速推进统计)。
*/
std::uint64_t m_lastInputActivityMs = 0;
/**
* @brief 当前是否处于「交互中」(正在拖拽,或鼠标刚刚移动过)。
*
* 两个用途(都用它保证同一个判定口径):
* * `Paint` 决定边沿统计本帧的扫描预算(交互 ⇒ 小预算,保住跟手);
* * `PanelGlobalKey` / `PanelSlotKey` 决定**是否把边沿统计数字与光标时间计入面板像素缓存键**:
* 交互期间不计入 ⇒ 键保持稳定 ⇒ 面板直接复用上一帧像素、**不做**每帧数百次统计查询
* 与整块重绘。
*
* ⚠️ 用户 2026-11-02 报告:288 个信号合并到同一网格、开启中心轴边沿计数后,
* 拖动横向光标/移动鼠标每帧要 30ms。根因正是「每帧 × 每个信号 × 一次
* `EdgeCenterCountsFor` 区间汇总」被塞进了缓存键计算里,且结果每帧都让键变化
* ⇒ 面板缓存永远不命中、每帧重画整块面板。
*/
bool InputIsInteractive() const;
/**
* @brief 上一帧鼠标悬停在「均值滤波 / 弱峰调整」面板的哪个**条目栏**上。
*
@@ -2878,6 +3017,28 @@ namespace jdi
*/
int m_meanRowHoverLane = -1;
SignalRef m_meanRowHoverRef;
/**
* @brief 悬停所在的**面板种类**(MEAN / CENTER / PEAK)。
*
* ⚠️ 必须区分:同一条信号可能在多个面板里各有一行(均值滤波、中心轴边沿计数、
* 弱峰调整都用同一套条目栏),只比 `(lane, ref)` 会把**所有**面板的同名行一起点亮,
* 并且无法判断“该高亮哪一条预览曲线”。
*
* 用户 2026-11-02 报告:只开弱峰调整 + 中心轴边沿计数时悬停**毫无反应**;
* 一旦再开均值滤波,三个面板悬停都只高亮均值滤波曲线 ——
* 根因就是绘制侧只给 MEAN 分支传了 `highlight`,而悬停状态没有记录面板种类。
*/
EdgePanelKind m_meanRowHoverKind = EdgePanelKind::MEAN;
/**
* @brief 上一帧绘制面板时是否使用了「冻结的缓存键」(交互期间)。
*
* 用途:冻结期间面板显示的是上一帧像素(统计数字停留在“统计中…”),
* 而**鼠标停下后不会再有重绘请求** ⇒ 面板永远停在旧数字
*(用户 2026-11-02 报告:“松开左键后一直统计中…,动一下鼠标或悬停面板才更新”)。
* 宿主每帧检查它:为真就武装一个短周期定时器,直到某个非交互帧重画面板为止。
*/
mutable bool m_panelKeysFrozen = false;
// ---------------------------------------------------------------- 自由标记
@@ -3013,9 +3174,59 @@ namespace jdi
* 用户看到的就是“按住光标不动时统计卡住、要动鼠标才更新”。
*/
mutable std::uint64_t m_edgeScanFrame = 0;
/**
* @brief 上一次“请求下一帧”发生在哪一帧(收敛成**每帧最多一次** `PostMessage`)。
*
* ⚠️ 旧实现在 `EdgeScanFor` 里按“未完成状态”逐个 Post:288 条信号时单帧 288 次,
* 把消息队列和重绘打满,`WM_TIMER`(分组折起动画)因此被饿死(用户 2026-11-02 报告)。
*/
mutable std::uint64_t m_edgeScanRequestFrame = static_cast<std::uint64_t>(-1);
/** 诊断计数:中心轴本地曲线计算失败次数(内存不足等)。0 = 一切正常。 */
mutable std::size_t m_edgeScanLocalCurveFails = 0;
/**
* @brief **统计数据版本号**:任何累积状态推进(`scannedPair` 前进)或扫完都会自增。
*
* 用途:面板像素缓存键不能再逐个信号查询统计数字(288 个信号 ⇒ 单帧 288 次查询,
* 且中间数字每帧都变 ⇒ 缓存永不命中 ⇒ 每帧整块重画上百行)。改为混入"把
* `m_edgeScanRevision` 按节流规则转成的 `m_panelStatsVersion`"(O(1)、零查询):
* 统计推进 ⇒ 版本号变化 ⇒ 面板重绘;没推进 ⇒ 键稳定 ⇒ 贴回缓存。
*/
mutable std::uint64_t m_edgeScanRevision = 0;
/** @brief 已反映到面板缓存键里的统计版本号(见 `m_edgeScanRevision`)。 */
mutable std::uint64_t m_panelStatsVersion = 0;
/** @brief 上一次同步时看到的 `m_edgeScanRevision`(用于判断"有新进展")。 */
mutable std::uint64_t m_panelStatsSeen = 0;
/**
* @brief 本帧统计扫描的**时间预算截止点**(QPC 计数)。
*
* 旧实现用"每帧采样对配额"限制开销,但同样的配额在稀疏/稠密、普通/中心轴之间
* 的实际耗时能差 10 倍:配额小 ⇒ 大文件统计极慢(用户 2026-11-02 报告
* "VERY_BIG_DATA 统计太慢");配额大 ⇒ 中心轴(每个采样对还要算窗口均值)单帧卡住。
* 改为**按时间切**:扫描循环每处理一个块检查一次是否超时,既不会卡帧又能把
* "便宜"的普通计数一次性推进得很快。
*/
mutable std::int64_t m_edgeScanDeadline = 0;
/**
* @brief 宿主正在加载文件(模态遮罩期间):暂停统计推进与面板绘制。
*
* ⚠️ 加载线程的主循环会不断 `UpdateWindow` 触发 `Render()`,而面板绘制里含统计推进
* 与上百行 GDI+ 原语 ⇒ 加载进度被拖慢(用户报告"统计没算完就无法加载文件")。
* 加载期间画布被遮罩覆盖,面板本来也看不见,因此直接跳过。
*/
bool m_loadingBusy = false;
/**
* @brief 累积状态上限(随可见信号总数缩放)。
*
* 每个信号最多两类状态(普通边沿计数 / 中心轴边沿计数),因此上限 ≥ `2 × 信号数`。
* 固定上限(旧 64)在 288 条信号合并到同一网格时会让所有状态互相淘汰 ⇒
* 面板永远停在“统计中…”(用户 2026-11-02 报告)。
*/
std::size_t EdgeScanStateLimit() const;
/**
* @brief 边沿统计的统一实现(普通边沿计数与中心轴边沿计数共用)。
*
@@ -3750,6 +3961,69 @@ namespace jdi
*/
mutable std::vector<gd::RectF> m_decorCapturedRects;
// ---- 「派生预览曲线」像素缓存(均值滤波 / 中心轴边沿计数 / 弱峰调整的叠加曲线)----
//
// 为什么需要单独一层像素缓存(用户 2026-11-02 反馈“极端工况下所有交互都卡”):
// 每条预览曲线都要按「逐像素列包络带」填充,代价 ∝ **覆盖像素数**。15 个信号 ×
// 3 个功能 = 45 条曲线、哪怕单条只要 2ms,一帧也要 90ms —— 而其中**绝大多数帧
// 画面根本没变**(拖动光标、悬停、静止)。因此与装饰层同一套思路:
// 曲线画进一张带 alpha 的 32bpp DIB,正常帧只做一次 `AlphaBlend`(约 1~2ms)。
//
// 平移复用(关键):视界**只平移不改跨度**时,画面内容只是横向平移 —— 于是把 DIB
// 的像素整体 memmove 一个 dx,再只重画**新露出的那条竖带**即可(与宽缓冲区
// 的滚动逻辑同构),因此拖动视界的每帧代价与信号数无关。
//
// ⚠️ 与装饰层的区别:本层的**内容随视界变化**,因此必须记录渲染时的视界
// (`m_derivedViewMin/Max`);跨度一变就必须整块重画。
/**
* @brief 确保派生曲线缓存位图尺寸正确(不足则重建为 32bpp DIB 段)。
* @param [IN] width 位图宽度。 @param [IN] height 位图高度。
* @return 可用返回 true。
*/
bool EnsureDerivedCache(int width, int height);
/** @brief 释放派生曲线缓存位图并作废缓存。 */
void ReleaseDerivedCache();
/**
* @brief 把全部派生预览曲线绘制进透明缓存位图。
*
* `m_derivedStripActive == true` 时只重画 `[m_derivedStripX0, m_derivedStripX1)` 这段竖带
* (平移后的“新露出”区域),否则整块重画。
* @return 成功返回 true。
*/
bool RenderDerivedCache();
/**
* @brief 把派生曲线缓存按 `AlphaBlend` 贴回后备缓冲区(仅绘图区)。
* @param [IN,OUT] g 绘图上下文。 @return 贴图成功返回 true。
*/
bool BlitDerivedCache(gd::Graphics& g);
/**
* @brief 派生曲线缓存的内容指纹(**不含视界**:视界单独比对,用于判断能否滚动复用)。
* @return 指纹。
*/
std::uint64_t DerivedOverlayKey() const;
/**
* @brief 按当前视界/内容刷新派生曲线缓存,并把结果贴回后备缓冲区。
*
* 三种情形:内容与视界都相同 → 只贴图;内容相同且跨度不变(纯平移)→ 滚动 + 补条带;
* 其余 → 整块重画。
* @param [IN,OUT] g 绘图上下文。
*/
void PaintDerivedOverlayLayer(gd::Graphics& g);
mutable HDC m_derivedDc = nullptr; /**< 派生曲线缓存设备上下文。 */
mutable HBITMAP m_derivedBitmap = nullptr; /**< 派生曲线缓存位图。 */
mutable HGDIOBJ m_derivedOldBitmap = nullptr; /**< DC 原选位图。 */
mutable void* m_derivedBits = nullptr; /**< 位图像素(32bpp DIB 段)。 */
mutable int m_derivedW = 0; /**< 缓存宽度。 */
mutable int m_derivedH = 0; /**< 缓存高度。 */
mutable bool m_derivedValid = false; /**< 缓存内容是否有效。 */
mutable std::uint64_t m_derivedContentKey = 0; /**< 缓存对应的内容指纹(不含视界)。 */
mutable double m_derivedViewMin = 0.0; /**< 缓存渲染时的视界下限。 */
mutable double m_derivedViewMax = 0.0; /**< 缓存渲染时的视界上限。 */
mutable bool m_derivedStripActive = false; /**< 当前是否只重画一条竖带。 */
mutable float m_derivedStripX0 = 0.0f; /**< 竖带左边界(客户区像素)。 */
mutable float m_derivedStripX1 = 0.0f; /**< 竖带右边界(客户区像素)。 */
// ---- 每网格「信号图层」(前台同步维护;交互帧只做贴图 / 坐标偏移) ----
//
// 平移只改变绘制坐标、不改变画面内容。因此**每个网格各自维护一块离屏位图**
+93 -1
View File
@@ -57,6 +57,32 @@ namespace jdi
inline const size_t kMaxPolylinePoints = 20000; /**< 单条折线最大顶点数(上限保护)。 */
inline const size_t kPolylinePointsPerPixel = 2; /**< 每个像素列允许的折线顶点数(上/下包络两顶点)。 */
inline const size_t kMinPolylinePoints = 512; /**< 折线最少顶点数(窗口很窄时仍保留细节)。 */
/**
* @brief 派生预览曲线走「逐像素列包络带」时,每列抽样几个采样。
*
* 稠密视界(可见采样数 > 像素列数)下预览曲线若按抽稀折线绘制,相邻顶点的 y
* 会整幅跳变 ⇒ GDI+ 描边代价 ∝ 覆盖像素数,实测单条曲线 76~128ms。
* 改为逐列记录上下包络后一次 FillPath,单条曲线降到约 2ms(约 60 倍)。
* 每列抽样 4 个点即可得到饱满的包络(再多只影响饱满度,不影响代价量级)。
*/
inline const size_t kDerivedCurveSamplesPerColumn = 4;
/**
* @brief 派生预览曲线包络带的**列密度分母**:每 N 个设备像素一列。
*
* 包络带的填充代价主要取决于**边数**与**覆盖面积**(实测同一形状 1600 列 ≈ 24ms、
* 400 列 ≈ 6.4ms):预览曲线是半透明叠加层、且本来就是“逐列 min/max”的聚合结果,
* 横向 4px 的分辨率损失在稠密视界下不可辨(尖峰仍不会丢:每列取的是该列的极值)。
* 稀疏区间仍走逐点折线(见 `PaintDerivedCurve`),不受影响。
*/
inline const size_t kDerivedCurveColumnDivisor = 4;
/**
* @brief 判定“极密视界”的采样密度阈值(采样/设备像素),超过时包络带列密度降到
* `kDerivedCurveColumnDivisor`,否则用 2 像素一列。
*
* 中等密度(1~8 采样/像素)下预览曲线形状本身还有细节,用 2 像素一列即可保住观感;
* 极密视界下每列内部就是一条满幅噪声带,4 像素一列肉眼无差别、代价再降一半。
*/
inline const size_t kDerivedCurveVeryDenseSamplesPerPixel = 8;
/**
* @brief 图层**纵向滚动**的量化阈值(位图像素)。
*
@@ -625,8 +651,43 @@ namespace jdi
* 而系统时钟粒度约 15.6ms —— 当帧率高于它时(`PostMessage` 自持重绘会把帧率拉高),
* 同一 tick 内的多帧**完全不推进**(用户报告:“按住横向光标不动时统计卡在统计中”,
* “回车确认参数后不移动鼠标就一直停在部分值”)。
*
* ⚠️⚠️ **这是「非交互帧」的预算**:用户 2026-11-02 报告——288 个信号合并到同一网格、
* 开启中心轴边沿计数后,拖动横向光标每帧要 30ms(实测),界面完全跟不上手。
* 原因就是本预算太大:中心轴每个采样对还要按“均值点数 × 迭代次数”算一次均值窗口,
* 1200000 对 × 11 次浮点运算 ≈ 3 位千万级运算/帧。
* 现在交互期间改用 `kEdgeScanPairsPerFrameInteractive`(见 `Paint` 开头)。
*/
inline const std::size_t kEdgeScanPairsPerFrame = 1200000;
inline const std::size_t kEdgeScanPairsPerFrame = 8000000;
/**
* @brief **交互帧**(正在拖拽 / 鼠标刚移动过)允许的边沿统计**采样对**配额。
*
* 真正限制开销的是下面的「时间预算」(`m_edgeScanDeadline`):同样的采样对数在
* 普通计数与中心轴(每个对还要算窗口均值)之间能差一个量级,因此**按时间切**:
* 采样对配额只负责“公平分摊”(不让第一个状态吃光全帧)。
*/
inline const std::size_t kEdgeScanPairsPerFrameInteractive = 2000000;
/**
* @brief 非交互帧的统计扫描**时间预算**(毫秒)。
*
* ⚠️ 用户 2026-11-02 报告“统计太慢(VERY_BIG_DATA)”:旧的固定采样对配额在
* 普通计数(1~2ns/对)下把一帧的工作量限得太死,而中心轴(≈7 倍成本)又嫌太大。
* 改成按时间切之后,便宜的计数能一次性推进数倍,贵的也绝不会卡住一帧。
*/
inline const float kEdgeScanTimeBudgetMs = 6.0f;
/** @brief 交互帧的统计扫描时间预算(毫秒):优先保证界面跟手。 */
inline const float kEdgeScanTimeBudgetInteractiveMs = 1.5f;
/**
* @brief 鼠标停止移动多久之后恢复高统计预算(毫秒)。
*/
inline const std::uint64_t kEdgeScanInteractiveIdleMs = 140;
/**
* @brief 统计数据版本号在**交互期间**的刷新间隔(帧数)。
*
* 交互帧里统计会继续推进,但每推进一次就重画上百行的面板太贵;节流后
* “鼠标一直动、面板一直停在旧数字”的缺陷也修掉了(最慢 ~4 帧 ≈ 60ms 内刷新一次)。
*/
inline const std::uint64_t kEdgeScanStatsRefreshFrames = 4;
/**
* @brief 每个累积状态每帧的扫描配额下限(防止多个状态时前面的把预算吃光)。
*
@@ -682,6 +743,37 @@ namespace jdi
return (!complete && rising == 0 && falling == 0);
}
/**
* @brief 表格单元格里的**纯数字**(「边沿计数」表格用)。
*
* 表头已经写了「上升沿计数 / 下降沿计数」,单元格里再写「上升沿 N」就重复了;
* 未算完时在数字后追加 `…`(与列表式排版的语义一致)。
*/
inline std::wstring EdgeCountCellText(std::size_t value, bool complete)
{
std::wstring text = std::to_wstring(value);
if (!complete)
{
text += L" …";
}
return text;
}
/** @brief 「边沿计数」表格的表头文本(信号名 / 上升沿计数 / 下降沿计数)。 */
inline const wchar_t* EdgeCountTableHeader(int column)
{
switch (column)
{
case 0:
return L"信号名";
case 1:
return L"上升沿计数";
case 2:
default:
return L"下降沿计数";
}
}
/**
* @brief 「边沿计数」结果的显示文本(合并成一行,用于宽度测量与兼容旧调用)。
*
+166 -7
View File
@@ -1414,6 +1414,8 @@ namespace jdi
if (lane.EdgeCenterEnabled(ref))
{
lane.RemoveEdgeCenterItem(ref);
// 该信号的累积统计状态一并释放(否则会占着 `kEdgeScanStateLimit` 的名额)。
InvalidateEdgeScansFor(ResolveSignal(ref).data, true);
return false;
}
lane.EnsureEdgeCenterItem(ref);
@@ -1809,6 +1811,57 @@ namespace jdi
return true;
}
bool PlotView::SetLaneEdgeCenterScroll(int laneIndex, float scroll)
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
const float clamped = std::max(0.0f, scroll);
if (std::fabs(lane.edgeCenterScroll - clamped) < 0.5f)
{
return false;
}
// 滚动只影响面板内部像素,缓冲区/场景都不变(面板像素缓存按键失效即可)。
lane.edgeCenterScroll = clamped;
return true;
}
bool PlotView::SetLaneWeakPeakScroll(int laneIndex, float scroll)
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
const float clamped = std::max(0.0f, scroll);
if (std::fabs(lane.weakPeakScroll - clamped) < 0.5f)
{
return false;
}
// 滚动只影响面板内部像素,缓冲区/场景都不变(面板像素缓存按键失效即可)。
lane.weakPeakScroll = clamped;
return true;
}
bool PlotView::SetLaneEdgeCountScroll(int laneIndex, float scroll)
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
const float clamped = std::max(0.0f, scroll);
if (std::fabs(lane.edgeCountScroll - clamped) < 0.5f)
{
return false;
}
// 滚动只影响面板内部像素,缓冲区/场景都不变(面板像素缓存按键失效即可)。
lane.edgeCountScroll = clamped;
return true;
}
bool PlotView::ToggleLaneFreeMarkerGroup(int laneIndex, const SignalRef& ref)
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
@@ -1967,7 +2020,8 @@ namespace jdi
int points, int iterations, int amountPercent,
std::size_t begin, std::size_t end,
std::size_t& spanBegin,
bool allowAsyncCompute) const
bool allowAsyncCompute,
bool computeIfMissing) const
{
spanBegin = 0;
const std::size_t count = signal.values.size();
@@ -2005,6 +2059,34 @@ namespace jdi
return &hit->values;
}
// 1b) 「只读」模式(光标取值胶囊):**绝不新建计算任务 / 条目**。
if (!computeIfMissing)
{
if (const CurveEntry* stale = AcquireNewestCurve(key, begin, end, spanBegin))
{
return &stale->values;
}
return nullptr;
}
// 1b) “只读”模式(光标取值胶囊):**绝不新建计算任务/条目**。
//
// ⚠️⚠️ 为什么必须这么做(用户 2026-11-02 反馈“拖光标时有明显卡顿”):
// 胶囊只为“光标所在的那两个采样”取值,请求窗口只有几个采样 —— 但缓存键里含窗口,
// 而窗口随光标每帧都在变 ⇒ 每帧都未命中 ⇒ 每帧同步计算并 `PublishCurve` 一次。
// 15 信号 × 3 种曲线 = 每帧 45 次发布,条目列表不断变长,`Acquire*` 的线性搜索
// 也随之变慢(实测拖动光标单帧 200ms,其中约 150ms 来自这 45 次重算 + 发布)。
// 现在:命中就取(绘制路径发布的窗口**带保护带**、覆盖整个可见区间,正常情况一定命中),
// 没命中就退到同 (信号, 种类) 的最新曲线,再没有就本帧不标注。
if (!computeIfMissing)
{
if (const CurveEntry* stale = AcquireNewestCurve(key, begin, end, spanBegin))
{
return &stale->values;
}
return nullptr;
}
// 2) 代价较大时交后台计算,本帧先用最新旧曲线顶替(双缓冲)。
// 这样绘制线程永远不做重计算,拖动滑块/光标/视界/缩放的反馈因此始终跟手。
//
@@ -2590,6 +2672,20 @@ namespace jdi
// 预算也就永远不推进(用户报告:"面板过去很久都一直显示统计中,
// 只有当鼠标瞄到浮动面板上时才会显示统计好的值")。
// 与派生曲线就绪用同一条消息(宿主只失效绘图区、不置脏,代价极小)。
//
// ⚠️⚠️ **每个绘制帧最多 Post 一次**(用户 2026-11-02 报告“上百条信号时所有交互都卡”):
// 本函数在 `EdgeScanFor` 里按“未完成的状态”逐个调用,288 条信号时单帧会 Post 288 次!
// 大量 `WM_MSG`+重绘会把优先级更低的 `WM_TIMER` 饿死(分组折起动画因此永远结束不了),
// 同时把 CPU 占满、拖慢所有交互。用一个“本帧已请求”标志收敛成一次。
if (m_loadingBusy)
{
return; // 加载中:不推进、不自请求(否则加载进度被统计重绘拖慢)
}
if (m_edgeScanRequestFrame == m_edgeScanFrame)
{
return;
}
m_edgeScanRequestFrame = m_edgeScanFrame;
const HWND host = m_host;
if (host != nullptr)
{
@@ -2597,9 +2693,22 @@ namespace jdi
}
}
void PlotView::InvalidateEdgeScansFor(const SignalData* signal, bool centreOnly)
std::size_t PlotView::EdgeScanStateLimit() const
{
// ⚠️ **定向作废**:只有被改动的那个信号(并且只限需要的那一类)要重算,
// 每个信号最多两类累积状态(普通边沿计数 / 中心轴边沿计数),另留一点余量。
// ⚠️ 不能用“当前网格内信号数”:边缘面板可能同时开在多个网格上,
// 而这里用全仓可见信号总数更省心(多出来的容量只是几个几十 KB 的块和数组)。
std::size_t signalCount = 0;
if (m_dataSet != nullptr)
{
signalCount = m_dataSet->SignalCount();
}
const std::size_t scaled = signalCount * 2u + 16u;
return std::max(kEdgeScanStateLimit, scaled);
}
void PlotView::InvalidateEdgeScansFor(const SignalData* signal, bool centreOnly)
{ // ⚠️ **定向作废**:只有被改动的那个信号(并且只限需要的那一类)要重算,
// 其它信号的键没有变化、累计的块和依然有效 → 必须原样保留。
// 否则(过去用 `m_edgeScans.clear()`)会连**普通边沿计数**与**其它信号的中心轴统计**
// 一起丢掉,于是两个浮动面板里的每一个数字都要从头重新扫描:
@@ -2629,6 +2738,14 @@ namespace jdi
falling = 0;
complete = false;
// ⚠️ **宿主正在加载文件时不推进**:加载线程的主循环不断 `UpdateWindow` 触发整帧绘制,
// 若这里继续扫,加载会被统计抢走 CPU(用户报告“统计没算完就无法开始加载”)。
// 加载完成后数据层结构变化会整体作废累积状态,本来就要重算。
if (m_loadingBusy)
{
return;
}
const std::size_t count = signal.values.size();
if (count < 2)
{
@@ -2679,7 +2796,13 @@ namespace jdi
}
if (state == nullptr)
{
if (m_edgeScans.size() >= kEdgeScanStateLimit)
// ⚠️⚠️ **累积状态上限必须随“信号数”缩放**(用户 2026-11-02 报告:288 条信号合并到
// 同一网格、开启「边沿计数」后**全部停在“统计中…”**且永不更新):
// 旧上限固定 64,而面板会在一帧内为**每条信号**各创建一个状态(288 条 > 64)⇒
// 新建状态时总是把队首(尚未扫完的)淘汰 ⇒ 没有任何状态能活到扫完 ⇒ 永远重头开始。
// 每个信号最多两类状态(普通 / 中心轴),因此上限取 `2×信号数 + 余量`。
const std::size_t stateLimit = EdgeScanStateLimit();
if (m_edgeScans.size() >= stateLimit)
{
// ⚠️ 超限时**优先淘汰「已扫完」的最旧状态**(它重算很快、而且通常已经不需要再变),
// 绝不淘汰正在推进的那个 —— 否则其它信号会被迫从头重扫
@@ -2718,6 +2841,7 @@ namespace jdi
created.justReset = true;
m_edgeScans.push_back(std::move(created));
state = &m_edgeScans.back();
++m_edgeScanRevision; // 新状态:让面板重算(可能刚从“统计中…”切换)
}
else if (!centre && state->threshold != threshold)
{
@@ -2732,6 +2856,7 @@ namespace jdi
state->justReset = true;
std::fill(state->blockRising.begin(), state->blockRising.end(), 0);
std::fill(state->blockFalling.begin(), state->blockFalling.end(), 0);
++m_edgeScanRevision;
}
// ---- 中心轴:**就地**计算所需的那一小段均值滤波曲线 ---------------------
@@ -2872,10 +2997,24 @@ namespace jdi
const bool suppressPartial = state->justReset && !state->complete;
if (!state->complete && !state->blockRising.empty())
{
const std::size_t activeStates = std::max<std::size_t>(1, m_edgeScans.size());
// ⚠️ 分母只数**还没扫完**的状态:已完成的状态不再消耗配额,若把它们也算进分母,
// 上百条短信号里夹着几条千万级长信号时,长信号每帧只能分到 1/300 的预算
// (实测要 20 分钟才出结果)。按“未完成数”分摊后,短信号一完成就把预算让出来。
std::size_t activeStates = 0;
for (const EdgeScanState& candidate : m_edgeScans)
{
if (!candidate.complete)
{
++activeStates;
}
}
activeStates = std::max<std::size_t>(1, activeStates);
const std::size_t share = std::max<std::size_t>(kEdgeScanFairShare, activeStates);
const std::size_t perState =
std::max<std::size_t>(kEdgeScanPairsPerStateMin, kEdgeScanPairsPerFrame / share);
// ⚠️ **不要给每状态一个固定下限**(旧实现是 `max(60000, budget/share)`):
// 状态数一多,下限就让**前几个状态**(恰好是网格里最前面的信号)吃掉整帧预算,
// 后面的信号永远分不到配额 ⇒ 一直显示“统计中…”(用户 2026-11-02 报告的
// 288 信号场景)。改为纯按未完成状态数平分本帧预算(至多 1)。
const std::size_t perState = std::max<std::size_t>(1, m_edgeScanBudget / share);
if (state->pumpedFrame != m_edgeScanFrame)
{
state->pumpedFrame = m_edgeScanFrame;
@@ -2893,6 +3032,17 @@ namespace jdi
std::size_t scanned = 0;
std::size_t pair = state->scannedPair;
std::size_t block = (pair > 0) ? ((pair - 1) / kEdgeBlockSamples) : 0;
/** @brief 本帧的**时间预算**是否已用完(每块检查一次,QPC 开销可忽略)。 */
const auto timeUp = [this]() -> bool
{
if (m_edgeScanDeadline <= 0)
{
return false;
}
LARGE_INTEGER now = {};
QueryPerformanceCounter(&now);
return now.QuadPart >= m_edgeScanDeadline;
};
while (pair < count && scanned < quota && m_edgeScanBudget > 0)
{
const std::size_t blockFirst = block * kEdgeBlockSamples + 1;
@@ -2961,6 +3111,10 @@ namespace jdi
{
++block;
}
if (timeUp())
{
break; // 本帧的时间预算用完:剩下的帧接着扫
}
}
state->scannedPair = pair;
state->complete = (pair >= count);
@@ -2976,6 +3130,11 @@ namespace jdi
}
state->pumpedPairs += scanned;
m_edgeScanBudget -= std::min(m_edgeScanBudget, scanned);
if (scanned > 0)
{
// 数字(或“是否扫完”)变了 ⇒ 面板缓存键要跟着变,否则会一直贴旧像素。
++m_edgeScanRevision;
}
}
}
+434 -256
View File
@@ -651,19 +651,26 @@ namespace jdi
target == PlotEditTarget::MEAN_ITERATIONS_MAX)
{
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
if (lane.FindMeanFilterItem(m_editMeanRef) == nullptr)
const bool centerEdit = (m_editMeanKind == EdgePanelKind::CENTER);
const Lane::MeanFilterItem* existing = centerEdit
? lane.FindEdgeCenterItem(m_editMeanRef)
: lane.FindMeanFilterItem(m_editMeanRef);
if (existing == nullptr)
{
return true;
}
if (parsedOk)
{
const bool isPoints = (target == PlotEditTarget::MEAN_POINTS_MAX);
const int minimum = isPoints ? kMeanFilterPointsMin : kMeanFilterIterationsMin;
const int minimum =
isPoints ? (centerEdit ? 0 : kMeanFilterPointsMin) : kMeanFilterIterationsMin;
const int limit = isPoints ? kMeanFilterPointsMaxLimit : kMeanFilterIterationsMaxLimit;
const long long value = static_cast<long long>(std::llround(parsed));
if (value >= minimum && value <= limit)
{
Lane::MeanFilterItem& item = lane.EnsureMeanFilterItem(m_editMeanRef);
Lane::MeanFilterItem& item = centerEdit
? lane.EnsureEdgeCenterItem(m_editMeanRef)
: lane.EnsureMeanFilterItem(m_editMeanRef);
if (isPoints)
{
item.maxPoints = static_cast<int>(value);
@@ -676,6 +683,10 @@ namespace jdi
std::max(minimum, std::min(item.maxIterations, item.iterations));
}
m_meanCurveCache.clear();
if (centerEdit)
{
InvalidateEdgeScansFor(ResolveSignal(m_editMeanRef).data, true);
}
}
}
return true;
@@ -1060,46 +1071,102 @@ namespace jdi
}
}
}
// 「弱峰调整」的逐信号滑块:轨道 → 左右箭头;迭代次数右侧数值 → 手型(可点击改上限)。
if (static_cast<EdgePanelKind>(panelKind) == EdgePanelKind::PEAK)
// 「均值滤波 / 中心轴边沿计数 / 弱峰调整」可滚动列表:
// * 条目栏:**只有该信号已被勾选**时才给手型(未勾选时点击毫无效果,
// 用户 2026-11-02 明确要求此时不要改变鼠标指针形状);
// * 滑块轨道 → 左右箭头;数值(可点击改上限)与复位按钮 → 手型;
// * 内容区滚动条 → 上下箭头。
{
for (std::size_t rowIndex = 0; rowIndex < layout.rowIterValues.size(); ++rowIndex)
const EdgePanelKind cursorKind = static_cast<EdgePanelKind>(panelKind);
const bool listCursor = (cursorKind == EdgePanelKind::MEAN ||
cursorKind == EdgePanelKind::CENTER ||
cursorKind == EdgePanelKind::PEAK);
const Lane& cursorLane =
Tab().Lanes()[static_cast<std::size_t>(panelLane)];
if (listCursor)
{
if (layout.rowIterValues[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowIterValues[rowIndex], static_cast<float>(x),
static_cast<float>(y)))
for (std::size_t rowIndex = 0; rowIndex < layout.rowHeaders.size(); ++rowIndex)
{
return LoadCursorW(nullptr, IDC_HAND);
}
// 两行滑块右侧的**复位控件**:与其它可点击图标一致,用手型光标提示可点击。
if (rowIndex < layout.rowLevelResets.size() &&
layout.rowLevelResets[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowLevelResets[rowIndex], static_cast<float>(x),
static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
if (rowIndex < layout.rowIterResets.size() &&
layout.rowIterResets[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowIterResets[rowIndex], static_cast<float>(x),
static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
if (rowIndex < layout.rowIterTracks.size() &&
WeakPeakSliderHit(layout.rowIterTracks[rowIndex], m_scale,
static_cast<float>(x), static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_SIZEWE);
}
if (rowIndex < layout.rowLevelTracks.size() &&
WeakPeakSliderHit(layout.rowLevelTracks[rowIndex], m_scale,
static_cast<float>(x), static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_SIZEWE);
if (layout.rowHeaders[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowHeaders[rowIndex], static_cast<float>(x),
static_cast<float>(y)))
{
bool checked = false;
if (cursorKind == EdgePanelKind::PEAK)
{
checked = cursorLane.FindWeakPeakItem(
layout.rowRefs[rowIndex]) != nullptr;
}
else if (cursorKind == EdgePanelKind::CENTER)
{
checked = cursorLane.FindEdgeCenterItem(
layout.rowRefs[rowIndex]) != nullptr;
}
else
{
checked = cursorLane.FindMeanFilterItem(
layout.rowRefs[rowIndex]) != nullptr;
}
return checked ? LoadCursorW(nullptr, IDC_HAND) : nullptr;
}
const bool hasParams =
rowIndex < layout.rowLevelTracks.size() &&
layout.rowLevelTracks[rowIndex].Width > 0.0f;
if (!hasParams)
{
continue; // 参数行未展开(未勾选 / 已收起)⇒ 该行没有可交互控件
}
// 「均值点数 / 调整幅度」数值:均值滤波与中心轴可点击改上限(弱峰不能)。
if (cursorKind != EdgePanelKind::PEAK &&
rowIndex < layout.rowLevelValues.size() &&
layout.rowLevelValues[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowLevelValues[rowIndex], static_cast<float>(x),
static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
if (rowIndex < layout.rowIterValues.size() &&
layout.rowIterValues[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowIterValues[rowIndex], static_cast<float>(x),
static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
if (rowIndex < layout.rowLevelResets.size() &&
layout.rowLevelResets[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowLevelResets[rowIndex], static_cast<float>(x),
static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
if (rowIndex < layout.rowIterResets.size() &&
layout.rowIterResets[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowIterResets[rowIndex], static_cast<float>(x),
static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
if (rowIndex < layout.rowLevelTracks.size() &&
WeakPeakSliderHit(layout.rowLevelTracks[rowIndex], m_scale,
static_cast<float>(x), static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_SIZEWE);
}
if (rowIndex < layout.rowIterTracks.size() &&
WeakPeakSliderHit(layout.rowIterTracks[rowIndex], m_scale,
static_cast<float>(x), static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_SIZEWE);
}
}
}
if (layout.scrollThumb.Width > 0.0f &&
UiPointInRect(layout.scrollThumb, static_cast<float>(x), static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_SIZENS);
}
}
// 面板主体(含底部编辑行与参数输入框):保持原始箭头。
return nullptr;
}
@@ -1164,11 +1231,32 @@ namespace jdi
return nullptr;
}
bool PlotView::InputIsInteractive() const
{
if (m_dragMode != PlotDragMode::NONE)
{
return true;
}
if (m_lastInputActivityMs == 0)
{
return false;
}
const std::uint64_t now = GetTickCount64();
return (now >= m_lastInputActivityMs) &&
((now - m_lastInputActivityMs) < kEdgeScanInteractiveIdleMs);
}
bool PlotView::OnMouseMove(int x, int y)
{
const bool mouseMoved = (x != m_mouseX) || (y != m_mouseY);
m_mouseX = x;
m_mouseY = y;
// 交互活动时间戳:`Paint` 用它决定边沿统计本帧用「交互预算」还是「静止预算」。
// 只有真正的移动才更新 —— 悬停不动时统计应当全速推进。
if (mouseMoved)
{
m_lastInputActivityMs = GetTickCount64();
}
if (!HasDrawableContent())
{
@@ -1441,21 +1529,26 @@ namespace jdi
case PlotDragMode::MEAN_POINTS:
case PlotDragMode::MEAN_ITERATIONS:
{
// 「均值滤波」某信号的参数滑块:量程 = 下限 … 该信号自己的上限。
// 「均值滤波 / 中心轴边沿计数」某信号的参数滑块:量程 = 下限 … 该信号自己的上限。
if (m_tab == nullptr || m_meanDragLane < 0 ||
m_meanDragLane >= static_cast<int>(m_tab->Lanes().size()))
{
break;
}
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(m_meanDragLane)];
if (lane.FindMeanFilterItem(m_meanDragRef) == nullptr)
const Lane::MeanFilterItem* existingItem = (m_meanDragKind == EdgePanelKind::CENTER)
? lane.FindEdgeCenterItem(m_meanDragRef)
: lane.FindMeanFilterItem(m_meanDragRef);
if (existingItem == nullptr)
{
break;
}
Lane::MeanFilterItem& item = lane.EnsureMeanFilterItem(m_meanDragRef);
Lane::MeanFilterItem& item = (m_meanDragKind == EdgePanelKind::CENTER)
? lane.EnsureEdgeCenterItem(m_meanDragRef)
: lane.EnsureMeanFilterItem(m_meanDragRef);
const bool pointsMode = (m_dragMode == PlotDragMode::MEAN_POINTS);
const EdgePanelLayout layout =
EdgePanelLayoutFor(m_meanDragLane, EdgePanelKind::MEAN);
const EdgePanelLayout layout = EdgePanelLayoutFor(
m_meanDragLane, m_meanDragKind);
int rowIndex = -1;
for (std::size_t candidate = 0; candidate < layout.rowRefs.size(); ++candidate)
{
@@ -1480,13 +1573,15 @@ namespace jdi
UiSliderRatioFromX(tracks[static_cast<std::size_t>(rowIndex)], fx);
if (pointsMode)
{
const int maximum = std::max(
kMeanFilterPointsMin, std::min(kMeanFilterPointsMaxLimit, item.maxPoints));
item.points = std::max(
kMeanFilterPointsMin,
std::min(maximum,
kMeanFilterPointsMin +
static_cast<int>(std::lround(ratio * (maximum - kMeanFilterPointsMin)))));
// ⚠️ 下限:均值滤波 = 1(用户 2026-10-30 要求不能为 0);中心轴 = 0(0 = 不平滑)。
const int floorValue = (m_meanDragKind == EdgePanelKind::CENTER) ? 0 : kMeanFilterPointsMin;
const int maximum =
std::max(floorValue, std::min(kMeanFilterPointsMaxLimit, item.maxPoints));
item.points =
std::max(floorValue,
std::min(maximum,
floorValue +
static_cast<int>(std::lround(ratio * (maximum - floorValue)))));
}
else
{
@@ -1501,6 +1596,11 @@ namespace jdi
ratio * (maximum - kMeanFilterIterationsMin)))));
}
m_meanCurveCache.clear();
// 中心轴统计依赖参数 ⇒ 参数一变必须定向作废该信号的累积状态。
if ((m_meanDragKind == EdgePanelKind::CENTER))
{
InvalidateEdgeScansFor(ResolveSignal(m_meanDragRef).data, true);
}
m_dragMoved = true;
return true;
}
@@ -1639,14 +1739,14 @@ namespace jdi
}
case PlotDragMode::MEAN_SCROLL:
{
// 「均值滤波」面板内容滚动条:按滑块在轨道里的位置换算滚动偏移。
// 「均值滤波 / 中心轴边沿计数」面板内容滚动条:按滑块在轨道里的位置换算滚动偏移。
if (m_tab == nullptr || m_markerScrollDragLane < 0 ||
m_markerScrollDragLane >= static_cast<int>(m_tab->Lanes().size()))
{
break;
}
const EdgePanelLayout layout =
EdgePanelLayoutFor(m_markerScrollDragLane, EdgePanelKind::MEAN);
const EdgePanelLayout layout = EdgePanelLayoutFor(
m_markerScrollDragLane, m_meanDragKind);
const float travel = layout.scrollTrack.Height - layout.scrollThumb.Height;
const float maxScroll =
std::max(0.0f, layout.markerContentHeight - layout.content.Height);
@@ -1659,7 +1759,24 @@ namespace jdi
std::min(static_cast<float>(y) - m_markerScrollGrabOffset,
layout.scrollTrack.Y + travel));
const float ratio = (thumbTop - layout.scrollTrack.Y) / travel;
if (SetLaneMeanFilterScroll(m_markerScrollDragLane, ratio * maxScroll))
const float target = ratio * maxScroll;
bool scrolled = false;
switch (m_meanDragKind)
{
case EdgePanelKind::COUNT:
scrolled = SetLaneEdgeCountScroll(m_markerScrollDragLane, target);
break;
case EdgePanelKind::CENTER:
scrolled = SetLaneEdgeCenterScroll(m_markerScrollDragLane, target);
break;
case EdgePanelKind::PEAK:
scrolled = SetLaneWeakPeakScroll(m_markerScrollDragLane, target);
break;
default:
scrolled = SetLaneMeanFilterScroll(m_markerScrollDragLane, target);
break;
}
if (scrolled)
{
m_dragMoved = true;
return true;
@@ -1827,17 +1944,19 @@ namespace jdi
hoverPanelKind);
}
// 「均值滤波 / 弱峰调整」面板**条目栏**的悬停 → 高亮该信号的预览曲线
//(用户 2026-10-30 要求:"鼠标处于条目栏上方时,对应的预览曲线需要高亮提示")。
// 「均值滤波 / 弱峰调整 / 中心轴边沿计数」面板**条目栏**的悬停 → 高亮该条目
//(用户 2026-10-30 要求:"鼠标处于条目栏上方时,对应的预览曲线需要高亮提示";
// 中心轴边沿计数自 2026-11-02 起同样有条目栏,条目本身也会叠一层强调色)。
{
int hoverLane = -1;
SignalRef hoverRef;
EdgePanelKind hoverKind =
static_cast<EdgePanelKind>(hoverPanelKind);
if (overEdgePanel && m_tab != nullptr && panelLane >= 0 &&
(static_cast<EdgePanelKind>(hoverPanelKind) == EdgePanelKind::MEAN ||
static_cast<EdgePanelKind>(hoverPanelKind) == EdgePanelKind::PEAK))
(hoverKind == EdgePanelKind::MEAN || hoverKind == EdgePanelKind::PEAK ||
hoverKind == EdgePanelKind::CENTER))
{
const EdgePanelLayout layout =
EdgePanelLayoutFor(panelLane, static_cast<EdgePanelKind>(hoverPanelKind));
const EdgePanelLayout layout = EdgePanelLayoutFor(panelLane, hoverKind);
if (UiPointInRect(layout.content, static_cast<float>(x), static_cast<float>(y)))
{
for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex)
@@ -1859,13 +1978,12 @@ namespace jdi
}
}
}
if (hoverLane != m_meanRowHoverLane || !(hoverRef == m_meanRowHoverRef))
if (hoverLane != m_meanRowHoverLane || !(hoverRef == m_meanRowHoverRef) ||
(hoverLane >= 0 && hoverKind != m_meanRowHoverKind))
{
m_meanRowHoverLane = hoverLane;
m_meanRowHoverRef = hoverRef;
// 预览曲线在部分绘制路径里属于"静态场景"(整幅重绘会缓存它),
// 因此悬停状态一变就作废场景缓存 —— 只在"进出某一行"时发生,不是每帧。
InvalidateSceneCache();
m_meanRowHoverKind = hoverKind;
changed = true;
}
}
@@ -2232,18 +2350,46 @@ namespace jdi
// 「中心轴边沿计数」/「均值滤波」/「弱峰调整」面板:逐信号条目
// (前两者是「名称 + 复选框 + 两个参数输入框」,弱峰调整是「名称 + 复选框 + 两行滑块」)。
if ((kind == EdgePanelKind::MEAN || kind == EdgePanelKind::CENTER ||
kind == EdgePanelKind::PEAK) &&
kind == EdgePanelKind::PEAK || kind == EdgePanelKind::COUNT) &&
m_tab != nullptr)
{
const bool meanMode = (kind == EdgePanelKind::MEAN);
const bool centerMode = (kind == EdgePanelKind::CENTER);
const bool boostMode = (kind == EdgePanelKind::PEAK);
// 「均值滤波」:组头三角(展开 / 收起参数行)+ 两行参数滑块
// 「均值滤波」「中心轴边沿计数」「弱峰调整」自 2026-11-02 起共用同一套
//**可滚动列表**交互(信号色叠底条目栏 + 右侧复选框 + 展开后的两行参数滑块)。
const bool listMode = (meanMode || centerMode || boostMode);
// 「边沿计数」= **表格**(表头 + 每信号一行),只有滚动条可拖。
if (kind == EdgePanelKind::COUNT)
{
if (layout.scrollThumb.Width > 0.0f &&
UiPointInRect(layout.scrollThumb, fx, fy))
{
m_dragMode = PlotDragMode::MEAN_SCROLL;
m_markerScrollDragLane = index;
m_markerScrollGrabOffset = fy - layout.scrollThumb.Y;
m_meanDragKind = EdgePanelKind::COUNT;
return true;
}
}
// 「列表式面板」:组头三角(展开 / 收起参数行)+ 两行参数滑块
//(轨道拖动 / 点击数值改上限 / 右侧复位按钮)。
// ⚠️ 所有命中都要求落在**内容区**之内:滚动到可视范围之外的行仍保留矩形,
// 若不加这条限制,它们会抢走底部「构建为新信号」按钮的点击。
if (meanMode)
if (listMode)
{
Lane& meanLane = m_tab->Lanes()[static_cast<std::size_t>(index)];
// 本面板的条目查找 / 创建(两者共用 `Lane::MeanFilterItem`)。
const auto findItem = [&meanLane, centerMode](const SignalRef& ref)
-> const Lane::MeanFilterItem* {
return centerMode ? meanLane.FindEdgeCenterItem(ref)
: meanLane.FindMeanFilterItem(ref);
};
const auto ensureItem = [&meanLane, centerMode](const SignalRef& ref)
-> Lane::MeanFilterItem& {
return centerMode ? meanLane.EnsureEdgeCenterItem(ref)
: meanLane.EnsureMeanFilterItem(ref);
};
// 内容区右侧的滚动条滑块:按下即进入拖动(与「自由标记」面板同一交互)。
if (layout.scrollThumb.Width > 0.0f &&
UiPointInRect(layout.scrollThumb, fx, fy))
@@ -2251,10 +2397,110 @@ namespace jdi
m_dragMode = PlotDragMode::MEAN_SCROLL;
m_markerScrollDragLane = index;
m_markerScrollGrabOffset = fy - layout.scrollThumb.Y;
m_meanDragKind = kind;
return true;
}
const bool insideContent =
UiPointInRect(layout.content, fx, fy);
// 「弱峰调整」:两行滑块 = 调整幅度(-400% … +400%)/ 迭代次数(1 … 自定上限)。
// 语义与该面板旧式排版完全一致,只是外观改成与均值滤波同款的可滚动列表。
if (boostMode)
{
for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex)
{
const SignalRef& ref = layout.rowRefs[rowIndex];
if (!insideContent)
{
break;
}
const Lane::WeakPeakItem* peakItem = meanLane.FindWeakPeakItem(ref);
const bool hasParams =
peakItem != nullptr && rowIndex < layout.rowLevelTracks.size() &&
layout.rowLevelTracks[rowIndex].Width > 0.0f;
if (!hasParams)
{
continue;
}
Lane::WeakPeakItem& item = meanLane.EnsureWeakPeakItem(ref);
// 复位:调整幅度 → 0%
if (rowIndex < layout.rowLevelResets.size() &&
layout.rowLevelResets[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowLevelResets[rowIndex], fx, fy))
{
item.amountPercent = 0;
m_weakPeakCurveCache.clear();
m_dragMode = PlotDragMode::NONE;
return true;
}
// 复位:迭代次数 → 1(上限恢复为默认上限)。
if (rowIndex < layout.rowIterResets.size() &&
layout.rowIterResets[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowIterResets[rowIndex], fx, fy))
{
item.iterations = kWeakPeakIterationsMin;
item.maxIterations = kWeakPeakIterationsDefaultMax;
m_weakPeakCurveCache.clear();
m_dragMode = PlotDragMode::NONE;
return true;
}
// 迭代次数行的**数值文本**:点击后整行变成输入框以修改滑块上限。
// ⚠️ 必须放在轨道命中之前(数值区在轨道右侧,命中区不重叠,但顺序更安全)。
if (rowIndex < layout.rowIterValues.size() &&
layout.rowIterValues[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowIterValues[rowIndex], fx, fy))
{
m_editMeanRef = ref;
m_peakEditCurrentMax = item.maxIterations;
const gd::RectF& track = layout.rowIterTracks[rowIndex];
const gd::RectF& value = layout.rowIterValues[rowIndex];
const gd::RectF rowRect(track.X, value.Y, value.X + value.Width - track.X,
value.Height);
BeginEdit(PlotEditTarget::PEAK_ITERATIONS_MAX, index, L"", rowRect);
m_edit.Begin(L"", false, EditFilter::BYTE, 0u);
m_dragMode = PlotDragMode::NONE;
return true;
}
// 拖动「迭代次数」滑块。
if (rowIndex < layout.rowIterTracks.size() &&
WeakPeakSliderHit(layout.rowIterTracks[rowIndex], m_scale, fx, fy))
{
m_dragMode = PlotDragMode::PEAK_ITERATIONS;
m_peakDragLane = index;
m_peakDragRef = ref;
const double ratio =
UiSliderRatioFromX(layout.rowIterTracks[rowIndex], fx);
const int peakMax = std::max(
kWeakPeakIterationsMin,
std::min(kWeakPeakIterationsMaxLimit, item.maxIterations));
item.iterations = std::max(
kWeakPeakIterationsMin,
std::min(peakMax,
kWeakPeakIterationsMin +
static_cast<int>(std::lround(
ratio * (peakMax - kWeakPeakIterationsMin)))));
m_weakPeakCurveCache.clear();
return true;
}
// 拖动「调整幅度」滑块(0 在正中间 ⇒ ±400%)。
if (rowIndex < layout.rowLevelTracks.size() &&
WeakPeakSliderHit(layout.rowLevelTracks[rowIndex], m_scale, fx, fy))
{
m_dragMode = PlotDragMode::PEAK_LEVEL;
m_peakDragLane = index;
m_peakDragRef = ref;
item.amountPercent = static_cast<int>(std::lround(
(UiSliderRatioFromX(layout.rowLevelTracks[rowIndex], fx) * 2.0 - 1.0) *
100.0 * kWeakPeakSliderScale));
item.amountPercent = std::max(
kWeakPeakAmountMin,
std::min(kWeakPeakAmountMax, item.amountPercent));
m_weakPeakCurveCache.clear();
return true;
}
}
}
else
{
for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex)
{
const SignalRef& ref = layout.rowRefs[rowIndex];
@@ -2267,10 +2513,10 @@ namespace jdi
const bool hasParams =
rowIndex < layout.rowLevelTracks.size() &&
layout.rowLevelTracks[rowIndex].Width > 0.0f &&
meanLane.FindMeanFilterItem(ref) != nullptr;
findItem(ref) != nullptr;
if (hasParams)
{
Lane::MeanFilterItem& item = meanLane.EnsureMeanFilterItem(ref);
Lane::MeanFilterItem& item = ensureItem(ref);
// 复位:均值点数 → 默认值(同时把滑块上限恢复为默认上限)。
if (rowIndex < layout.rowLevelResets.size() &&
layout.rowLevelResets[rowIndex].Width > 0.0f &&
@@ -2279,6 +2525,10 @@ namespace jdi
item.points = kMeanFilterPointsDefault;
item.maxPoints = kMeanFilterPointsDefaultMax;
m_meanCurveCache.clear();
if (centerMode)
{
InvalidateEdgeScansFor(ResolveSignal(ref).data, true);
}
m_dragMode = PlotDragMode::NONE;
return true;
}
@@ -2290,6 +2540,10 @@ namespace jdi
item.iterations = kMeanFilterIterationsMin;
item.maxIterations = kMeanFilterIterationsDefaultMax;
m_meanCurveCache.clear();
if (centerMode)
{
InvalidateEdgeScansFor(ResolveSignal(ref).data, true);
}
m_dragMode = PlotDragMode::NONE;
return true;
}
@@ -2300,6 +2554,7 @@ namespace jdi
{
m_editMeanRef = ref;
m_meanEditCurrentMax = item.maxPoints;
m_editMeanKind = kind;
const gd::RectF& track = layout.rowLevelTracks[rowIndex];
const gd::RectF& value = layout.rowLevelValues[rowIndex];
const gd::RectF rowRect(track.X, value.Y,
@@ -2317,6 +2572,7 @@ namespace jdi
{
m_editMeanRef = ref;
m_meanEditCurrentMax = item.maxIterations;
m_editMeanKind = kind;
const gd::RectF& track = layout.rowIterTracks[rowIndex];
const gd::RectF& value = layout.rowIterValues[rowIndex];
const gd::RectF rowRect(track.X, value.Y,
@@ -2331,21 +2587,23 @@ namespace jdi
if (rowIndex < layout.rowLevelTracks.size() &&
WeakPeakSliderHit(layout.rowLevelTracks[rowIndex], m_scale, fx, fy))
{
const int maximum = std::max(
kMeanFilterPointsMin,
std::min(kMeanFilterPointsMaxLimit, item.maxPoints));
const int floorValue = centerMode ? 0 : kMeanFilterPointsMin;
const int maximum =
std::max(floorValue, std::min(kMeanFilterPointsMaxLimit, item.maxPoints));
m_dragMode = PlotDragMode::MEAN_POINTS;
m_meanDragLane = index;
m_meanDragRef = ref;
m_meanDragKind = kind;
const double ratio = UiSliderRatioFromX(layout.rowLevelTracks[rowIndex], fx);
item.points = std::max(
kMeanFilterPointsMin,
floorValue,
std::min(maximum,
kMeanFilterPointsMin +
static_cast<int>(std::lround(
ratio *
(maximum - kMeanFilterPointsMin)))));
floorValue + static_cast<int>(std::lround(ratio * (maximum - floorValue)))));
m_meanCurveCache.clear();
if (centerMode)
{
InvalidateEdgeScansFor(ResolveSignal(ref).data, true);
}
return true;
}
// 拖动「迭代次数」滑块。
@@ -2358,203 +2616,100 @@ namespace jdi
m_dragMode = PlotDragMode::MEAN_ITERATIONS;
m_meanDragLane = index;
m_meanDragRef = ref;
m_meanDragKind = kind;
const double ratio = UiSliderRatioFromX(layout.rowIterTracks[rowIndex], fx);
item.iterations = std::max(
kMeanFilterIterationsMin,
std::min(maximum,
kMeanFilterIterationsMin +
static_cast<int>(std::lround(
ratio *
(maximum - kMeanFilterIterationsMin)))));
ratio * (maximum - kMeanFilterIterationsMin)))));
m_meanCurveCache.clear();
if (centerMode)
{
InvalidateEdgeScansFor(ResolveSignal(ref).data, true);
}
return true;
}
}
// 条目栏(信号色叠底那一整行):
// * 点在右端复选框上 → 切换"是否启用该信号的预览"(与复选框同一语义,
// 因为整行已被本函数消费,复选框自己收不到点击了);
// * 已启用 → 整行切换参数行的收起 / 展开;
// * **未启用 → 什么都不做**(用户 2026-10-30 明确要求:未勾选前点击条目栏
// 既不能自动勾选、也不能展开 / 收起,只能点复选框)。
// 仍然消费这次点击(不穿透到底层的平移/选区等)。
if (rowIndex < layout.rowHeaders.size() &&
layout.rowHeaders[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowHeaders[rowIndex], fx, fy))
{
const bool onCheckBox =
rowIndex < layout.rowChecks.size() &&
layout.rowChecks[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowChecks[rowIndex], fx, fy);
Lane::MeanFilterItem* existing = const_cast<Lane::MeanFilterItem*>(
meanLane.FindMeanFilterItem(ref));
if (onCheckBox)
{
ToggleLaneMeanFilterSignal(index, ref);
m_meanRowHoverLane = -1;
m_meanRowHoverRef = SignalRef();
}
else if (existing != nullptr)
{
// 已启用:整行切换参数行的收起 / 展开。
existing->collapsed = !existing->collapsed;
}
m_dragMode = PlotDragMode::NONE;
return true;
}
}
}
Lane& tabLane = m_tab->Lanes()[static_cast<std::size_t>(index)];
// 「弱峰调整」:先判两行滑块(数值文本 → 改上限;轨道 → 拖动)。
if (boostMode)
{
for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex)
{
const SignalRef& ref = layout.rowRefs[rowIndex];
const bool hasLevel = rowIndex < layout.rowLevelTracks.size() &&
layout.rowLevelTracks[rowIndex].Width > 0.0f;
if (!hasLevel)
{
continue;
}
if (tabLane.FindWeakPeakItem(ref) == nullptr)
{
continue;
}
Lane::WeakPeakItem& item = tabLane.EnsureWeakPeakItem(ref);
// 两行滑块右侧的**复位控件**:点击恢复该行参数的默认值
//(幅度 → 0;迭代次数 → 1 且上限回到初始上限 `kWeakPeakIterationsDefaultMax`)。
// 与「系数/偏移」二级面板里的还原图标同一交互语义(面板保持打开、就地回显)。
if (rowIndex < layout.rowLevelResets.size() &&
layout.rowLevelResets[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowLevelResets[rowIndex], fx, fy))
{
item.amountPercent = 0;
m_weakPeakCurveCache.clear();
m_dragMode = PlotDragMode::NONE;
return true;
}
if (rowIndex < layout.rowIterResets.size() &&
layout.rowIterResets[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowIterResets[rowIndex], fx, fy))
{
item.iterations = kWeakPeakIterationsMin;
item.maxIterations = kWeakPeakIterationsDefaultMax;
m_weakPeakCurveCache.clear();
m_dragMode = PlotDragMode::NONE;
return true;
}
// 迭代次数行右侧的**数值文本**:点击后整行变成输入框以修改滑块上限。
// ⚠️ 必须放在轨道命中之前(数值区在轨道右侧,命中区不重叠,但顺序更安全)。
if (layout.rowIterValues[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowIterValues[rowIndex], fx, fy))
{
m_editMeanRef = ref;
m_peakEditCurrentMax = item.maxIterations;
// 输入框以**空内容**开始:空内容时显示当前上限作为灰色提示。
// 输入框覆盖"整条滑块行"(从轨道左端到数值文本右端)。
const gd::RectF& track = layout.rowIterTracks[rowIndex];
const gd::RectF& value = layout.rowIterValues[rowIndex];
const gd::RectF rowRect(track.X, value.Y, value.X + value.Width - track.X,
value.Height);
BeginEdit(PlotEditTarget::PEAK_ITERATIONS_MAX, index, L"", rowRect);
// `BeginEdit` 默认用 NUMBER 过滤(允许负号/小数点);这里改为**纯数字**,
// 从源头保证"只允许输入正整数"(负数、小数根本无法键入,粘贴同样被清洗)。
m_edit.Begin(L"", false, EditFilter::BYTE, 0u);
m_dragMode = PlotDragMode::NONE;
return true;
}
if (layout.rowIterTracks[rowIndex].Width > 0.0f &&
WeakPeakSliderHit(layout.rowIterTracks[rowIndex], m_scale, fx, fy))
{
m_dragMode = PlotDragMode::PEAK_ITERATIONS;
m_peakDragLane = index;
m_peakDragRef = ref;
const double ratio = UiSliderRatioFromX(layout.rowIterTracks[rowIndex], fx);
const int peakMax = std::max(kWeakPeakIterationsMin,
std::min(kWeakPeakIterationsMaxLimit,
item.maxIterations));
item.iterations = std::max(
kWeakPeakIterationsMin,
std::min(peakMax, kWeakPeakIterationsMin +
static_cast<int>(std::lround(
ratio * (peakMax - kWeakPeakIterationsMin)))));
m_weakPeakCurveCache.clear();
return true;
}
if (layout.rowLevelTracks[rowIndex].Width > 0.0f &&
WeakPeakSliderHit(layout.rowLevelTracks[rowIndex], m_scale, fx, fy))
{
m_dragMode = PlotDragMode::PEAK_LEVEL;
m_peakDragLane = index;
m_peakDragRef = ref;
// 单击轨道:直接写入该处对应的幅度(-400% … +400%,0 在正中间)。
item.amountPercent = static_cast<int>(std::lround(
(UiSliderRatioFromX(layout.rowLevelTracks[rowIndex], fx) * 2.0 - 1.0) *
100.0 * kWeakPeakSliderScale));
item.amountPercent = std::max(
kWeakPeakAmountMin,
std::min(kWeakPeakAmountMax, item.amountPercent));
m_weakPeakCurveCache.clear();
return true;
}
}
}
// 条目栏(信号色叠底那一整行):
// * 点在右端复选框上 → 切换「是否启用该信号的预览」;
// * 已启用 → 整行切换参数行的收起 / 展开;
// * **未启用 → 什么都不做**(用户 2026-10-30 明确要求:未勾选前点击条目栏
// 既不能自动勾选、也不能展开 / 收起,只能点复选框;此时鼠标指针也保持箭头)。
// 仍然消费这次点击(不穿透到底层的平移 / 选区等)。
for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex)
{
const SignalRef& ref = layout.rowRefs[rowIndex];
// 第一排右侧的复选框 → 切换该信号的预览勾选状态。
if (layout.rowChecks[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowChecks[rowIndex], fx, fy))
if (!insideContent)
{
if (meanMode)
{
ToggleLaneMeanFilterSignal(index, ref);
}
else if (boostMode)
break;
}
const SignalRef& ref = layout.rowRefs[rowIndex];
if (rowIndex >= layout.rowHeaders.size() ||
layout.rowHeaders[rowIndex].Width <= 0.0f ||
!UiPointInRect(layout.rowHeaders[rowIndex], fx, fy))
{
continue;
}
const bool onCheckBox =
rowIndex < layout.rowChecks.size() &&
layout.rowChecks[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowChecks[rowIndex], fx, fy);
bool checked = false;
bool collapsed = false;
if (boostMode)
{
const Lane::WeakPeakItem* peakItem = meanLane.FindWeakPeakItem(ref);
checked = (peakItem != nullptr);
collapsed = (peakItem != nullptr && peakItem->collapsed);
}
else
{
const Lane::MeanFilterItem* meanItem = findItem(ref);
checked = (meanItem != nullptr);
collapsed = (meanItem != nullptr && meanItem->collapsed);
}
if (onCheckBox)
{
if (boostMode)
{
ToggleLaneWeakPeakSignal(index, ref);
}
else
else if (centerMode)
{
ToggleLaneEdgeCenterSignal(index, ref);
}
// 勾选变化只改变该信号“是否显示”,它的统计状态(与参数相关)依然有效,
// 其它信号更是完全无关 ⇒ **不要**清任何缓存(清掉就会让所有统计重算)。
// 这里**不**整体作废场景缓存(交给 `m_sceneDerived` 快照驱动局部重画)。
m_dragMode = PlotDragMode::NONE;
return true;
else
{
ToggleLaneMeanFilterSignal(index, ref);
}
m_meanRowHoverLane = -1;
m_meanRowHoverRef = SignalRef();
}
// 第二排的两个输入框 → 就地编辑该信号的参数(仅 MEAN / CENTER 面板;
// 弱峰调整用的是滑块,已在上面处理)。
if (layout.rowPoints[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowPoints[rowIndex], fx, fy))
else if (checked)
{
m_editMeanRef = ref;
const Lane::MeanFilterItem* item =
meanMode ? tabLane.FindMeanFilterItem(ref)
: tabLane.FindEdgeCenterItem(ref);
BeginEdit(meanMode ? PlotEditTarget::MEAN_FILTER_POINTS
: PlotEditTarget::EDGE_CENTER_POINTS,
index, std::to_wstring(item != nullptr ? item->points : 5),
layout.rowPoints[rowIndex]);
return true;
}
if (layout.rowIterations[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowIterations[rowIndex], fx, fy))
{
m_editMeanRef = ref;
const Lane::MeanFilterItem* item =
meanMode ? tabLane.FindMeanFilterItem(ref)
: tabLane.FindEdgeCenterItem(ref);
BeginEdit(meanMode ? PlotEditTarget::MEAN_FILTER_ITERATIONS
: PlotEditTarget::EDGE_CENTER_ITERATIONS,
index, std::to_wstring(item != nullptr ? item->iterations : 1),
layout.rowIterations[rowIndex]);
return true;
// 已启用:整行切换参数行的收起 / 展开。
if (boostMode)
{
Lane::WeakPeakItem* item =
const_cast<Lane::WeakPeakItem*>(meanLane.FindWeakPeakItem(ref));
item->collapsed = !collapsed;
}
else
{
Lane::MeanFilterItem* item =
const_cast<Lane::MeanFilterItem*>(findItem(ref));
item->collapsed = !collapsed;
}
}
m_dragMode = PlotDragMode::NONE;
return true;
}
}
if (boostMode && layout.button.Width > 0.0f && UiPointInRect(layout.button, fx, fy))
{
@@ -3057,9 +3212,10 @@ namespace jdi
// 弱峰滑块拖拽结束:清掉目标(避免留下上一轮的信号引用)。
m_peakDragLane = -1;
m_peakDragRef = SignalRef();
// 「均值滤波」参数滑块拖拽结束:同样清掉目标。
// 「均值滤波 / 中心轴边沿计数」参数滑块拖拽结束:同样清掉目标。
m_meanDragLane = -1;
m_meanDragRef = SignalRef();
m_meanDragKind = EdgePanelKind::MEAN;
// 「标记透明度」滑块 / 面板内容滚动条拖拽结束。
m_markerOpacityDragLane = -1;
m_markerScrollDragLane = -1;
@@ -3111,10 +3267,12 @@ namespace jdi
panelLane, panelKind))
{
const EdgePanelKind hoveredKind = static_cast<EdgePanelKind>(panelKind);
// 「均值滤波」面板:内容区超高时用滚轮翻页(上百个信号合并时必备)。
if (hoveredKind == EdgePanelKind::MEAN)
// 「均值滤波 / 中心轴边沿计数 / 弱峰调整 / 边沿计数」面板:内容区超高时用滚轮翻页
//(上百个信号合并时必备)。
if (hoveredKind == EdgePanelKind::MEAN || hoveredKind == EdgePanelKind::CENTER ||
hoveredKind == EdgePanelKind::PEAK || hoveredKind == EdgePanelKind::COUNT)
{
const EdgePanelLayout layout = EdgePanelLayoutFor(panelLane, EdgePanelKind::MEAN);
const EdgePanelLayout layout = EdgePanelLayoutFor(panelLane, hoveredKind);
if (layout.content.Width > 0.0f &&
UiPointInRect(layout.content, static_cast<float>(m_mouseX),
static_cast<float>(m_mouseY)))
@@ -3123,11 +3281,32 @@ namespace jdi
std::max(0.0f, layout.markerContentHeight - layout.content.Height);
if (maxScroll > 0.0f)
{
const float current =
Tab().Lanes()[static_cast<std::size_t>(panelLane)].meanFilterScroll;
const Lane& wheelLane =
Tab().Lanes()[static_cast<std::size_t>(panelLane)];
const float current = (hoveredKind == EdgePanelKind::CENTER)
? wheelLane.edgeCenterScroll
: (hoveredKind == EdgePanelKind::PEAK)
? wheelLane.weakPeakScroll
: (hoveredKind == EdgePanelKind::COUNT)
? wheelLane.edgeCountScroll
: wheelLane.meanFilterScroll;
const float next = current - static_cast<float>(notches * 40.0 * m_scale);
SetLaneMeanFilterScroll(panelLane,
std::max(0.0f, std::min(next, maxScroll)));
const float target = std::max(0.0f, std::min(next, maxScroll));
switch (hoveredKind)
{
case EdgePanelKind::CENTER:
SetLaneEdgeCenterScroll(panelLane, target);
break;
case EdgePanelKind::PEAK:
SetLaneWeakPeakScroll(panelLane, target);
break;
case EdgePanelKind::COUNT:
SetLaneEdgeCountScroll(panelLane, target);
break;
default:
SetLaneMeanFilterScroll(panelLane, target);
break;
}
return true;
}
}
@@ -4012,4 +4191,3 @@ namespace jdi
} // namespace jdi
File diff suppressed because it is too large. Load diff
+273 -256
View File
@@ -28,8 +28,13 @@ namespace jdi
const bool centerAxis = (kind == EdgePanelKind::CENTER);
const bool meanFilter = (kind == EdgePanelKind::MEAN);
const bool weakPeak = (kind == EdgePanelKind::PEAK);
// 「均值滤波」「中心轴边沿计数」「弱峰调整」都是逐条目面板(名称 + 复选框)。
const bool itemized = (centerAxis || meanFilter || weakPeak);
// 「均值滤波」「中心轴边沿计数」「弱峰调整」= 可滚动的分组列表
//(信号色叠底条目栏 + 右侧复选框 + 展开后的参数行);
// **「边沿计数」= 表格**(表头:信号名 / 上升沿计数 / 下降沿计数)。
// ⚠️ 旧式「逐条目」排版(无滚动的名称+参数行)已彻底删除:上百个信号时它会算出
// 上万像素高的面板,超出绘图区后**面板像素缓存一次都存不进去** ⇒ 每帧整块重画。
const bool listStyle = (centerAxis || meanFilter || weakPeak);
const bool countTable = (kind == EdgePanelKind::COUNT);
// 标题行 = “GX” 徽标 + 标题(“边沿计数” / “中心轴边沿计数” / “均值滤波” / “弱峰调整”
// + 光标区间尾缀)。
@@ -63,16 +68,10 @@ namespace jdi
std::min(UiMeasureTextWidth(m_fonts->uiSmall, resolved.data->name),
kEdgePanelNameMaxWidth * m_scale);
nameColumnWidth = std::max(nameColumnWidth, nameWidth);
if (itemized)
if (listStyle)
{
// 逐条目面板:第一排 = 名称 + 复选框;第三排(中心轴)= 统计结果。
width = std::max(width, kEdgePanelPad * 2.0f * m_scale + nameWidth +
kEdgePanelMeanCheckSize * m_scale + 6.0f * m_scale);
}
if (meanFilter)
{
// 「均值滤波」的条目 = **信号色叠底标题栏**(三角 + 名称 + 复选框)
// 与两行滑块(标签 + 轨道 + 数值 + 复位按钮),右侧还要给滚动条留位。
// 条目 = **信号色叠底条目栏**(三角 + 名称 + 复选框)与参数行
//(标签 + 轨道 + 数值 + 复位按钮),右侧还要给滚动条留位。
const float groupWidth = kEdgePanelPad * 2.0f * m_scale +
kEdgePanelDisclosureWidth * m_scale + 6.0f * m_scale +
nameWidth + 6.0f * m_scale +
@@ -80,15 +79,30 @@ namespace jdi
(kFreeMarkerScrollWidth + 4.0f) * m_scale;
width = std::max(width, groupWidth);
}
if (!countTable && !centerAxis)
{
continue; // 只有「边沿计数」表格 / 「中心轴」统计行需要量计数列宽
}
std::size_t rising = 0;
std::size_t falling = 0;
bool complete = true;
if (meanFilter || weakPeak)
if (countTable)
{
continue;
EdgeCountsFor(*resolved.axis, *resolved.data, *resolved.style, lane.edgeValue,
EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling, complete);
// 宽度按实际要显示的文本测量(含未算完时的“…”),否则推进过程中数字变长会被截断。
const float pendingWidth = UiMeasureTextWidth(m_fonts->monoSmall, L"统计中…");
statRisingWidth = std::max(
statRisingWidth,
std::max(UiMeasureTextWidth(m_fonts->monoSmall, EdgeCountCellText(rising, complete)),
pendingWidth));
statFallingWidth =
std::max(statFallingWidth,
UiMeasureTextWidth(m_fonts->monoSmall, EdgeCountCellText(falling, complete)));
}
if (centerAxis)
else
{
// 「中心轴边沿计数」的统计行 = 「上升沿 N / 下降沿 M」**两列**(只量已勾选的条目)。
const Lane::EdgeCenterItem* item = lane.FindEdgeCenterItem(ref);
if (item == nullptr)
{
@@ -97,60 +111,55 @@ namespace jdi
EdgeCenterCountsFor(*resolved.axis, *resolved.data, *resolved.style, item->points,
item->iterations, EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(),
rising, falling, complete);
const float pendingWidth = UiMeasureTextWidth(m_fonts->monoSmall, L"统计中…");
statRisingWidth = std::max(
statRisingWidth,
std::max(UiMeasureTextWidth(m_fonts->monoSmall, EdgeRisingText(rising, complete)),
pendingWidth));
statFallingWidth = std::max(
statFallingWidth,
UiMeasureTextWidth(m_fonts->monoSmall, EdgeFallingText(falling, complete)));
}
else
{
EdgeCountsFor(*resolved.axis, *resolved.data, *resolved.style, lane.edgeValue,
EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling, complete);
}
// 宽度按实际要显示的文本测量(含未算完时的“…”),否则推进过程中数字变长会被截断。
// 完全未算出时整行显示“统计中…”,因此两列都要预留它的宽度。
const float pendingWidth = UiMeasureTextWidth(m_fonts->monoSmall, L"统计中…");
statRisingWidth = std::max(
statRisingWidth,
std::max(UiMeasureTextWidth(m_fonts->monoSmall, EdgeRisingText(rising, complete)),
pendingWidth));
statFallingWidth =
std::max(statFallingWidth,
UiMeasureTextWidth(m_fonts->monoSmall, EdgeFallingText(falling, complete)));
}
if (statRisingWidth > 0.0f || statFallingWidth > 0.0f)
if (countTable)
{
// 两列**等宽**(取两列最大值中的较大者)→ 绘制时两列起点固定,
// 多条信号的“上升沿 / 下降沿”文字严格垂直对齐(用户明确要求)。
// 「边沿计数」= **表格**(信号名 / 上升沿计数 / 下降沿计数):
// 宽度 = 名称列 + 两列计数(列宽取「表头文字」「实际数字」的较大者)+ 滚动条留位。
const float headerRising =
UiMeasureTextWidth(m_fonts->uiSmall, EdgeCountTableHeader(1));
const float headerFalling =
UiMeasureTextWidth(m_fonts->uiSmall, EdgeCountTableHeader(2));
const float risingColumn = std::max(headerRising, statRisingWidth) + 10.0f * m_scale;
const float fallingColumn = std::max(headerFalling, statFallingWidth) + 10.0f * m_scale;
const float gap = kEdgePanelStatColumnGap * m_scale;
width = std::max(width, kEdgePanelPad * 2.0f * m_scale +
nameColumnWidth + gap + risingColumn + gap + fallingColumn +
(kFreeMarkerScrollWidth + 4.0f) * m_scale);
}
else if (centerAxis && (statRisingWidth > 0.0f || statFallingWidth > 0.0f))
{
// 「中心轴」的统计行 = 两列**等宽**(列起点固定 ⇒ 多条信号严格垂直对齐)。
const float maxColumn = std::max(statRisingWidth, statFallingWidth);
const float countsWidth = maxColumn * 2.0f + kEdgePanelStatColumnGap * m_scale;
width = std::max(width, kEdgePanelPad * 2.0f * m_scale +
std::max(nameColumnWidth, countsWidth));
width = std::max(width, kEdgePanelPad * 2.0f * m_scale + countsWidth);
}
if (centerAxis || meanFilter)
{
// 参数行 = 两个「说明文字 + 数值框」,必须保证文字不被数值框压住。
// (CENTER:「均值点数 X」+「迭代次数 N」。MEAN 已改为滑块,见下面单独的分支。)
const float labelXWidth = UiMeasureTextWidth(m_fonts->uiSmall, L"均值点数 X");
const float labelN = UiMeasureTextWidth(m_fonts->uiSmall, L"迭代次数 N");
const float fieldWidth =
std::max(kEdgePanelEditFieldWidth * m_scale * 0.75f,
UiMeasureTextWidth(m_fonts->monoSmall, L"1000000") + 18.0f * m_scale);
const float halfWidth = std::max(labelXWidth, labelN) + 6.0f * m_scale + fieldWidth;
const float rowWidth = halfWidth * 2.0f + 8.0f * m_scale;
width = std::max(width, kEdgePanelPad * 2.0f * m_scale + rowWidth);
}
if (meanFilter)
if (listStyle)
{
// 两行滑块 = 「标签列 + 轨道(最小宽度)+ 数值列 + 复位按钮」(两行共用同一列宽),
// 右侧再给滚动条留位(上百个信号时内容必然超高)。
const float labelColumn =
std::max(UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterPointsSliderLabel),
UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterIterationsSliderLabel));
const float weakPeakLabelColumn =
std::max(UiMeasureTextWidth(m_fonts->uiSmall, kWeakPeakLevelLabel),
UiMeasureTextWidth(m_fonts->uiSmall, kWeakPeakIterationsSliderLabel));
const float valueColumn =
std::max(UiMeasureTextWidth(m_fonts->monoSmall, L"100000"),
UiMeasureTextWidth(m_fonts->monoSmall, L"1000"));
const float sliderRowWidth = labelColumn + 6.0f * m_scale + kWeakPeakTrackMinWidth * m_scale +
6.0f * m_scale + valueColumn + kWeakPeakResetGap * m_scale +
kWeakPeakResetButtonSize * m_scale;
UiMeasureTextWidth(m_fonts->monoSmall, L"-400%"));
const float sliderRowWidth = std::max(labelColumn, weakPeakLabelColumn) + 6.0f * m_scale +
kWeakPeakTrackMinWidth * m_scale + 6.0f * m_scale + valueColumn +
kWeakPeakResetGap * m_scale + kWeakPeakResetButtonSize * m_scale;
width = std::max(width, kEdgePanelPad * 2.0f * m_scale + sliderRowWidth +
(kFreeMarkerScrollWidth + 4.0f) * m_scale);
}
@@ -167,12 +176,19 @@ namespace jdi
if (meanFilter || weakPeak)
{
// 底部按钮行 = 「构建为新信号」,按按钮文字实测宽度确定下限。
// 底部按钮行 = 「构建为新信号」(仅均值滤波 / 弱峰调整有),按按钮文字实测宽度确定下限。
const float buttonWidth =
UiMeasureTextWidth(m_fonts->uiSmall, kBuildSignalLabel) + 28.0f * m_scale;
width = std::max(width, kEdgePanelPad * 2.0f * m_scale + buttonWidth);
}
if (countTable)
{
// 「边沿计数」表格:右侧要给内容滚动条留位(上百个信号时内容必然超高)。
width = std::max(width, kEdgePanelPad * 2.0f * m_scale +
(kFreeMarkerScrollWidth + 4.0f) * m_scale);
}
if (kind == EdgePanelKind::MARK)
{
// 「自由标记」面板:宽度 = 六列(取「缺省列宽」「实测内容宽度」「列标题宽度」三者
@@ -260,10 +276,10 @@ namespace jdi
const bool meanFilter = (kind == EdgePanelKind::MEAN);
const bool weakPeak = (kind == EdgePanelKind::PEAK);
const bool freeMarker = (kind == EdgePanelKind::MARK);
// 「均值滤波」与「弱峰调整」共用同一套「逐条目 + 底部按钮」排布。
// ⚠️ 「均值滤波」从 2026-10-30 起改为**可滚动的分组列表**(信号色叠底标题栏 +
// 展开后的两行滑块),与「自由标记」同一套浏览方式 ⇒ 不再走这里的 `itemized` 分支。
const bool itemized = (centerAxis || weakPeak);
// 「均值滤波」「中心轴边沿计数」「弱峰调整」共用同一套**可滚动分组列表**排布
//(信号色叠底条目栏 + 展开后的参数行);「边沿计数」自 2026-11-02 起走**表格**分支。
const bool listStyle = (meanFilter || centerAxis || weakPeak);
const bool countTable = (kind == EdgePanelKind::COUNT);
const bool hasButton = (meanFilter || weakPeak);
const bool enabled = meanFilter ? lane.meanFilter
: (weakPeak ? lane.weakPeak
@@ -305,27 +321,40 @@ namespace jdi
const float width = EdgePanelWidth(laneIndex, kind);
const float titleHeight = kEdgePanelTitleHeight * m_scale;
const float rowHeight = kEdgePanelRowHeight * m_scale;
const float pad = kEdgePanelPad * m_scale;
// 「中心轴边沿计数」「均值滤波」「弱峰调整」共用同一套「逐条目」排布,
// 因此不再有独立的底部编辑行;只有「边沿计数」保留两行文本式排版。
// 「中心轴边沿计数」「均值滤波」「弱峰调整」共用同一套**可滚动列表**排布,
// 因此不再有独立的底部编辑行;「边沿计数」走独立的**表格**分支。
const float buttonHeight = hasButton ? (kEdgePanelButtonHeight * m_scale) : 0.0f;
const float rowsPerSignal = meanFilter ? 1.0f : 2.0f;
// 面板底部(内容区之下)占的纵向空间:有按钮时 = pad + 按钮 + pad,无按钮时只有 pad。
const float footerHeight = hasButton ? (buttonHeight + pad) : 0.0f;
const float bottomPad = pad;
// 逐条目面板:每个条目 = 第一排(信号名 + 复选框)
// + 参数部分(**只在该条目被勾选时才占位**,因此面板高度随勾选数量动态变化):
// * MEAN / CENTER:一行「均值点数 X + 迭代次数 N」输入框(中心轴再多一行统计结果);
// * PEAK:两行滑块(调整幅度 % / 迭代次数 N),每个信号各有一套自己的参数。
const bool itemHasParams = (centerAxis || weakPeak);
const float weakPeakSliderRow = kWeakPeakSliderRowHeight * m_scale;
float meanRowHeight = 0.0f;
float itemsHeight = 0.0f;
float meanContentHeight = 0.0f;
float height = 0.0f;
if (meanFilter)
float tableHeaderHeight = 0.0f;
if (countTable)
{
// 「均值滤波」:内容区 = 逐信号的**信号色叠底标题栏**(内容总是显示,
// 用于勾选/取消预览)+(勾选且未收起时)两行参数滑块。
// 「边沿计数」= **表格**:表头(信号名 / 上升沿计数 / 下降沿计数)固定不滚动,
// 下面每条可见信号占一行(行底色 = 信号配色),内容超高时滚动。
const float rowH = kEdgePanelMeanRowHeight * m_scale;
const float content = static_cast<float>(rowCount) * rowH;
meanContentHeight = content;
tableHeaderHeight = rowH;
const float budget =
m_plotRect.Height - (titleHeight + pad + tableHeaderHeight + bottomPad);
const float maxContent =
std::max(3.0f * rowH, std::min(kFreeMarkerContentMaxHeight * m_scale,
std::max(3.0f * rowH, budget)));
const float view = std::min(content, maxContent);
height = titleHeight + pad + tableHeaderHeight + view + bottomPad;
}
else if (listStyle)
{
// 「均值滤波 / 中心轴边沿计数 / 弱峰调整」:内容区 = 逐信号的**信号色叠底条目栏**
//(内容总是显示,用于勾选/取消)+(勾选且未收起时)参数行。
// 「中心轴」的参数行 = 一行「上升沿 / 下降沿」统计 + 两行滑块;
// 「均值滤波」与「弱峰调整」= 两行滑块。
// 内容超高时出现滚动条;底部「构建为新信号」按钮固定不滚动。
const float groupRow = kEdgePanelMeanRowHeight * m_scale;
const float sliderRow = weakPeakSliderRow;
@@ -338,57 +367,49 @@ namespace jdi
continue;
}
content += groupRow;
const Lane::MeanFilterItem* item = lane.FindMeanFilterItem(ref);
if (item != nullptr && !item->collapsed)
bool expanded = false;
if (weakPeak)
{
const Lane::WeakPeakItem* item = lane.FindWeakPeakItem(ref);
expanded = (item != nullptr && !item->collapsed);
}
else if (centerAxis)
{
const Lane::EdgeCenterItem* item = lane.FindEdgeCenterItem(ref);
expanded = (item != nullptr && !item->collapsed);
}
else
{
const Lane::MeanFilterItem* item = lane.FindMeanFilterItem(ref);
expanded = (item != nullptr && !item->collapsed);
}
if (expanded)
{
content += sliderRow * 2.0f;
if (centerAxis)
{
content += groupRow; // 统计行(上升沿 / 下降沿)
}
}
}
meanContentHeight = content;
const float budget = m_plotRect.Height - (titleHeight + pad * 3.0f + buttonHeight);
const float budget =
m_plotRect.Height - (titleHeight + pad + bottomPad + footerHeight);
const float maxContent =
std::max(3.0f * groupRow,
std::min(kFreeMarkerContentMaxHeight * m_scale, std::max(3.0f * groupRow, budget)));
const float view = std::min(content, maxContent);
height = titleHeight + pad + view + pad + buttonHeight + pad;
height = titleHeight + pad + view + bottomPad + footerHeight;
}
else if (itemized)
else if (freeMarker)
{
meanRowHeight = kEdgePanelMeanRowHeight * m_scale;
for (const SignalRef& ref : lane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || !resolved.visible)
{
continue;
}
itemsHeight += meanRowHeight;
const bool checked = meanFilter ? lane.MeanFilterEnabled(ref)
: (weakPeak ? lane.WeakPeakEnabled(ref)
: lane.EdgeCenterEnabled(ref));
if (checked && itemHasParams)
{
if (weakPeak)
{
itemsHeight += weakPeakSliderRow * 2.0f;
}
else
{
itemsHeight += (kEdgePanelMeanEditHeight + kEdgePanelMeanRowGap) * m_scale;
if (centerAxis)
{
itemsHeight += kEdgePanelMeanRowHeight * m_scale;
}
}
}
}
// 底部按钮行:上方与条目之间、下方与面板下边界之间各留一个 `pad`,
// 与面板的左右内边距完全一致(原先按钮直接贴着下边框,看起来很不协调)。
height = titleHeight + pad + itemsHeight + pad + buttonHeight + (hasButton ? pad : 0.0f);
// 「自由标记」的高度在下面的 `if (freeMarker)` 分支里计算(含底部固定区)。
height = titleHeight;
}
else
{
height = titleHeight + pad + static_cast<float>(rowCount) * rowHeight * rowsPerSignal + pad;
// 不应到达:所有面板种类都已覆盖。保留一个保守的高度以免布局全零。
height = titleHeight + pad + bottomPad;
}
// 「自由标记」面板:按信号分组(**分组栏** + 下属固定点条目),
@@ -562,21 +583,126 @@ namespace jdi
{
return layout;
}
if (meanFilter)
if (countTable)
{
// 「均值滤波」:可滚动内容区(逐信号的信号色叠底标题栏 + 展开后的两行滑块)+ 底部按钮。
// 「边沿计数」= **表格**:表头固定、内容区可滚动。
// 列:① 信号名 ② 上升沿计数 ③ 下降沿计数(行底色 = 信号配色)。
const float rowH = kEdgePanelMeanRowHeight * m_scale;
const float scrollW = kFreeMarkerScrollWidth * m_scale;
const float viewHeight =
std::max(0.0f, height - titleHeight - pad - tableHeaderHeight - pad);
const bool scrollable = meanContentHeight > viewHeight + 0.5f;
const float maxScroll = std::max(0.0f, meanContentHeight - viewHeight);
const float scroll = std::max(0.0f, std::min(lane.edgeCountScroll, maxScroll));
const float innerWidth =
std::max(0.0f, width - pad * 2.0f - (scrollable ? (scrollW + 4.0f * m_scale) : 0.0f));
layout.tableHeader =
gd::RectF(left + pad, top + titleHeight + pad, innerWidth, tableHeaderHeight);
layout.content = gd::RectF(left + pad, layout.tableHeader.Y + tableHeaderHeight, innerWidth,
viewHeight);
layout.markerContentHeight = meanContentHeight;
layout.scrollOffset = scroll;
if (scrollable)
{
// 滚动条只占**内容区**(不覆盖固定的表头,表头永远干净可读)。
layout.scrollTrack =
gd::RectF(left + pad + innerWidth + 4.0f * m_scale, layout.content.Y, scrollW,
viewHeight);
const float thumbHeight =
std::max(18.0f * m_scale, viewHeight * (viewHeight / meanContentHeight));
const float thumbTop =
layout.content.Y +
(viewHeight - thumbHeight) * ((maxScroll > 0.0f) ? (scroll / maxScroll) : 0.0f);
layout.scrollThumb = gd::RectF(layout.scrollTrack.X, thumbTop, scrollW, thumbHeight);
}
// 三列:名称列吃掉剩余宽度,两列计数按内容宽度固定 —— 表头与数据行严格对齐。
const float gap = kEdgePanelStatColumnGap * m_scale;
const float contentLeft = layout.content.X;
// 计数列宽 = 「表头文字」「当前数字」的较大者(与 `EdgePanelWidth` 同源)。
float risingCol =
UiMeasureTextWidth(m_fonts->uiSmall, EdgeCountTableHeader(1)) + 10.0f * m_scale;
float fallingCol =
UiMeasureTextWidth(m_fonts->uiSmall, EdgeCountTableHeader(2)) + 10.0f * m_scale;
for (const SignalRef& ref : lane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || resolved.axis == nullptr ||
resolved.style == nullptr || !resolved.visible)
{
continue;
}
std::size_t rising = 0;
std::size_t falling = 0;
bool complete = true;
EdgeCountsFor(*resolved.axis, *resolved.data, *resolved.style, lane.edgeValue,
EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling,
complete);
risingCol = std::max(
risingCol,
UiMeasureTextWidth(m_fonts->monoSmall, EdgeCountCellText(rising, complete)) +
10.0f * m_scale);
fallingCol = std::max(
fallingCol,
UiMeasureTextWidth(m_fonts->monoSmall, EdgeCountCellText(falling, complete)) +
10.0f * m_scale);
}
const float nameColumn =
std::max(20.0f * m_scale, innerWidth - gap - risingCol - gap - fallingCol);
layout.tableColumns[0].left = contentLeft;
layout.tableColumns[0].width = nameColumn;
layout.tableColumns[1].left = contentLeft + nameColumn + gap;
layout.tableColumns[1].width = risingCol;
layout.tableColumns[2].left = layout.tableColumns[1].left + risingCol + gap;
layout.tableColumns[2].width = fallingCol;
float rowY = layout.content.Y - scroll;
for (const SignalRef& ref : lane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || !resolved.visible)
{
continue;
}
layout.rowRefs.push_back(ref);
layout.rowHeaders.push_back(gd::RectF(contentLeft, rowY, innerWidth, rowH));
layout.rowNameRects.push_back(
gd::RectF(contentLeft + 4.0f * m_scale, rowY,
std::max(10.0f, nameColumn - 8.0f * m_scale), rowH));
layout.rowTriangles.push_back(gd::RectF());
layout.rowChecks.push_back(gd::RectF());
layout.rowPoints.push_back(gd::RectF());
layout.rowIterations.push_back(gd::RectF());
layout.rowStats.push_back(gd::RectF());
layout.rowLevelTracks.push_back(gd::RectF());
layout.rowLevelValues.push_back(gd::RectF());
layout.rowLevelResets.push_back(gd::RectF());
layout.rowIterTracks.push_back(gd::RectF());
layout.rowIterValues.push_back(gd::RectF());
layout.rowIterResets.push_back(gd::RectF());
rowY += rowH;
}
}
else if (listStyle)
{
// 「均值滤波 / 弱峰调整」:可滚动内容区(信号色叠底条目栏 + 展开后的两行滑块)
// +(均值滤波 / 弱峰调整)底部按钮。
const float buttonRowHeight = kEdgePanelButtonHeight * m_scale;
layout.button = gd::RectF(left + pad, top + height - pad - buttonRowHeight,
std::max(0.0f, width - pad * 2.0f), buttonRowHeight);
if (hasButton)
{
layout.button = gd::RectF(left + pad, top + height - pad - buttonRowHeight,
std::max(0.0f, width - pad * 2.0f), buttonRowHeight);
}
const float groupRow = kEdgePanelMeanRowHeight * m_scale;
const float sliderRow = kWeakPeakSliderRowHeight * m_scale;
const float scrollW = kFreeMarkerScrollWidth * m_scale;
const float footerHeight = hasButton ? (buttonRowHeight + pad) : 0.0f;
const float viewHeight =
std::max(0.0f, height - titleHeight - pad * 3.0f - buttonRowHeight);
std::max(0.0f, height - titleHeight - pad - pad - footerHeight);
const bool scrollable = meanContentHeight > viewHeight + 0.5f;
const float maxScroll = std::max(0.0f, meanContentHeight - viewHeight);
const float scroll = std::max(0.0f, std::min(lane.meanFilterScroll, maxScroll));
const float storedScroll = meanFilter ? lane.meanFilterScroll
: (weakPeak ? lane.weakPeakScroll : lane.edgeCenterScroll);
const float scroll = std::max(0.0f, std::min(storedScroll, maxScroll));
const float innerWidth =
std::max(0.0f, width - pad * 2.0f - (scrollable ? (scrollW + 4.0f * m_scale) : 0.0f));
layout.content = gd::RectF(left + pad, top + titleHeight + pad, innerWidth, viewHeight);
@@ -597,11 +723,13 @@ namespace jdi
// 两行滑块的列宽(与 `EdgePanelWidth` 同一组算式 ⇒ 布局与宽度不会漂移)。
const float labelColumn =
std::max(UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterPointsSliderLabel),
UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterIterationsSliderLabel));
std::max(std::max(UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterPointsSliderLabel),
UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterIterationsSliderLabel)),
std::max(UiMeasureTextWidth(m_fonts->uiSmall, kWeakPeakLevelLabel),
UiMeasureTextWidth(m_fonts->uiSmall, kWeakPeakIterationsSliderLabel)));
const float valueColumn =
std::max(UiMeasureTextWidth(m_fonts->monoSmall, L"100000"),
UiMeasureTextWidth(m_fonts->monoSmall, L"1000"));
UiMeasureTextWidth(m_fonts->monoSmall, L"-400%"));
const float resetSize = kWeakPeakResetButtonSize * m_scale;
const float resetGap = kWeakPeakResetGap * m_scale;
const float trackHeight = kWeakPeakTrackHeight * m_scale;
@@ -621,8 +749,26 @@ namespace jdi
{
continue;
}
const Lane::MeanFilterItem* item = lane.FindMeanFilterItem(ref);
const bool checked = (item != nullptr);
bool checked = false;
bool itemCollapsed = false;
if (weakPeak)
{
const Lane::WeakPeakItem* item = lane.FindWeakPeakItem(ref);
checked = (item != nullptr);
itemCollapsed = (item != nullptr && item->collapsed);
}
else if (centerAxis)
{
const Lane::EdgeCenterItem* item = lane.FindEdgeCenterItem(ref);
checked = (item != nullptr);
itemCollapsed = (item != nullptr && item->collapsed);
}
else
{
const Lane::MeanFilterItem* item = lane.FindMeanFilterItem(ref);
checked = (item != nullptr);
itemCollapsed = (item != nullptr && item->collapsed);
}
layout.rowRefs.push_back(ref);
// 组头(信号色叠底):整行宽度,左侧三角(仅勾选时)+ 名称,右端复选框。
layout.rowHeaders.push_back(
@@ -643,7 +789,7 @@ namespace jdi
rowY += groupRow;
// 参数行:只有勾选且未收起时才有几何(未勾选的行两者都是空矩形)。
if (!checked || item->collapsed)
if (!checked || itemCollapsed)
{
layout.rowLevelTracks.push_back(gd::RectF());
layout.rowLevelValues.push_back(gd::RectF());
@@ -653,6 +799,12 @@ namespace jdi
layout.rowIterResets.push_back(gd::RectF());
continue;
}
// 「中心轴边沿计数」:参数行最上面是**同一行**的「上升沿 / 下降沿」统计。
if (centerAxis && !layout.rowStats.empty())
{
layout.rowStats.back() = gd::RectF(contentLeft, rowY, innerWidth, groupRow);
rowY += groupRow;
}
const gd::RectF pointsTrack(trackLeft, rowY + (sliderRow - trackHeight) * 0.5f,
trackWidth, trackHeight);
layout.rowLevelTracks.push_back(pointsTrack);
@@ -674,141 +826,6 @@ namespace jdi
rowY += sliderRow;
}
}
else if (itemized)
{
const float buttonRowHeight = kEdgePanelButtonHeight * m_scale;
if (hasButton)
{
// 底部按钮与面板下边界之间留出与左右、与内容相同的 `pad` 间距
//(原先按钮行直接贴着下边框,视觉上与其它元素不协调);
// 面板高度已含这份 pad,因此这里只上移按钮行即可。
layout.button = gd::RectF(left + pad, top + height - pad - buttonRowHeight,
std::max(0.0f, width - pad * 2.0f), buttonRowHeight);
}
// 逐信号条目:第一排 = 名称 + 复选框(复选框靠右);
// 参数部分:MEAN / CENTER = 一行两个输入框(中心轴再多一行统计结果);
// PEAK = 两行滑块(每个信号各有自己的等级与迭代次数)。
const float checkSize = kEdgePanelMeanCheckSize * m_scale;
const float fieldHeight = kEdgePanelMeanEditHeight * m_scale;
const float halfWidth = std::max(40.0f, (width - pad * 2.0f - 8.0f * m_scale) * 0.5f);
// 滑块行列宽(与 `EdgePanelWidth` 使用同一组算式,避免布局与宽度计算漂移)。
const float weakPeakLabelWidth = WeakPeakLabelColumnWidth(m_fonts->uiSmall);
const float weakPeakValueWidth = WeakPeakValueColumnWidth(m_fonts->monoSmall);
const float weakPeakResetSize = kWeakPeakResetButtonSize * m_scale;
const float weakPeakResetGap = kWeakPeakResetGap * m_scale;
const float weakPeakTrackHeight = kWeakPeakTrackHeight * m_scale;
const float weakPeakTrackLeft = left + pad + weakPeakLabelWidth + 6.0f * m_scale;
const float weakPeakTrackWidth =
std::max(10.0f * m_scale,
(left + width - pad - weakPeakValueWidth - weakPeakResetGap -
weakPeakResetSize - 6.0f * m_scale) -
weakPeakTrackLeft);
float rowY = top + titleHeight + pad;
for (const SignalRef& ref : lane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || !resolved.visible)
{
continue;
}
layout.rowRefs.push_back(ref);
layout.rowChecks.push_back(gd::RectF(
left + width - pad - checkSize, rowY + (meanRowHeight - checkSize) * 0.5f, checkSize,
checkSize));
const bool checked = meanFilter ? lane.MeanFilterEnabled(ref)
: (weakPeak ? lane.WeakPeakEnabled(ref)
: lane.EdgeCenterEnabled(ref));
if (weakPeak)
{
// 每个被勾选的信号各有两行滑块:调整幅度(-400% – +400%)与迭代次数(1 … 上限)。
if (checked)
{
const float levelRowTop = rowY + meanRowHeight;
const float iterRowTop = levelRowTop + weakPeakSliderRow;
const gd::RectF levelTrack(
weakPeakTrackLeft,
levelRowTop + (weakPeakSliderRow - weakPeakTrackHeight) * 0.5f,
weakPeakTrackWidth, weakPeakTrackHeight);
const gd::RectF iterTrack(
weakPeakTrackLeft,
iterRowTop + (weakPeakSliderRow - weakPeakTrackHeight) * 0.5f,
weakPeakTrackWidth, weakPeakTrackHeight);
layout.rowLevelTracks.push_back(levelTrack);
layout.rowLevelValues.push_back(gd::RectF(
levelTrack.X + levelTrack.Width + 6.0f * m_scale, levelRowTop,
weakPeakValueWidth, weakPeakSliderRow));
layout.rowIterTracks.push_back(iterTrack);
layout.rowIterValues.push_back(gd::RectF(
iterTrack.X + iterTrack.Width + 6.0f * m_scale, iterRowTop,
weakPeakValueWidth, weakPeakSliderRow));
// 每行滑块右侧的**复位按钮**(与数值列同一右边界,保证两行上下对齐)。
const float resetX = levelTrack.X + levelTrack.Width + 6.0f * m_scale +
weakPeakValueWidth + weakPeakResetGap;
layout.rowLevelResets.push_back(
gd::RectF(resetX, levelRowTop + (weakPeakSliderRow - weakPeakResetSize) * 0.5f,
weakPeakResetSize, weakPeakResetSize));
layout.rowIterResets.push_back(
gd::RectF(resetX, iterRowTop + (weakPeakSliderRow - weakPeakResetSize) * 0.5f,
weakPeakResetSize, weakPeakResetSize));
}
else
{
layout.rowLevelTracks.push_back(gd::RectF());
layout.rowLevelValues.push_back(gd::RectF());
layout.rowLevelResets.push_back(gd::RectF());
layout.rowIterTracks.push_back(gd::RectF());
layout.rowIterValues.push_back(gd::RectF());
layout.rowIterResets.push_back(gd::RectF());
}
layout.rowPoints.push_back(gd::RectF());
layout.rowIterations.push_back(gd::RectF());
layout.rowStats.push_back(gd::RectF());
rowY += meanRowHeight;
if (checked)
{
rowY += weakPeakSliderRow * 2.0f;
}
continue;
}
if (checked && itemHasParams)
{
const float editTop = rowY + meanRowHeight;
layout.rowPoints.push_back(
gd::RectF(left + pad, editTop, halfWidth, fieldHeight));
layout.rowIterations.push_back(
gd::RectF(left + pad + halfWidth + 8.0f * m_scale, editTop, halfWidth, fieldHeight));
if (centerAxis)
{
layout.rowStats.push_back(
gd::RectF(left + pad, editTop + fieldHeight, width - pad * 2.0f,
kEdgePanelMeanRowHeight * m_scale));
}
else
{
layout.rowStats.push_back(gd::RectF());
}
}
else
{
layout.rowPoints.push_back(gd::RectF());
layout.rowIterations.push_back(gd::RectF());
layout.rowStats.push_back(gd::RectF());
}
rowY += meanRowHeight;
if (checked && itemHasParams)
{
rowY += (kEdgePanelMeanEditHeight + kEdgePanelMeanRowGap) * m_scale;
if (centerAxis)
{
rowY += kEdgePanelMeanRowHeight * m_scale;
}
}
}
}
if (freeMarker)
{
+300
View File
@@ -744,6 +744,306 @@ namespace jdi
return any;
}
// ---------------------------------------------------------------------------
// 「派生预览曲线」像素缓存
// ---------------------------------------------------------------------------
std::uint64_t PlotView::DerivedOverlayKey() const
{
// 内容指纹 = 场景内容(含外观样式 + 派生参数 + 网格幅值范围 + 网格滚动量 + 绘图区几何)。
// ⚠️ 视界**不在这里**:平移时要靠它判断“能否只滚动像素”,所以视界单独比对。
std::uint64_t hash = SceneContentHash(true, true, true, true);
// 后台算好的派生曲线到达时,曲线像素必须更新(`m_curveSerial` 随每次发布自增)。
{
std::lock_guard<std::mutex> lock(m_curveMutex);
hash ^= m_curveSerial * 1099511628211ull;
}
// 高亮状态**不参与**:高亮由前景单独绘制(见 `PaintDerivedHoverHighlight`)。
return hash;
}
bool PlotView::EnsureDerivedCache(int width, int height)
{
if (width <= 0 || height <= 0)
{
return false;
}
if (m_derivedDc != nullptr && m_derivedBits != nullptr && m_derivedW == width &&
m_derivedH == height)
{
return true;
}
ReleaseDerivedCache();
HDC screen = GetDC(nullptr);
if (screen == nullptr)
{
return false;
}
// 与装饰层同理:必须是 32bpp DIB 段(带真 alpha + 像素可直接 memmove 滚动)。
BITMAPINFO info = {};
info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
info.bmiHeader.biWidth = width;
info.bmiHeader.biHeight = -height; // 自上而下
info.bmiHeader.biPlanes = 1;
info.bmiHeader.biBitCount = 32;
info.bmiHeader.biCompression = BI_RGB;
void* bits = nullptr;
HBITMAP dib = CreateDIBSection(screen, &info, DIB_RGB_COLORS, &bits, nullptr, 0);
ReleaseDC(nullptr, screen);
if (dib == nullptr || bits == nullptr)
{
if (dib != nullptr)
{
DeleteObject(dib);
}
return false;
}
HDC dc = CreateCompatibleDC(nullptr);
if (dc == nullptr)
{
DeleteObject(dib);
return false;
}
m_derivedOldBitmap = SelectObject(dc, dib);
m_derivedDc = dc;
m_derivedBitmap = dib;
m_derivedBits = bits;
m_derivedW = width;
m_derivedH = height;
m_derivedValid = false;
return true;
}
void PlotView::ReleaseDerivedCache()
{
m_derivedValid = false;
if (m_derivedDc != nullptr)
{
if (m_derivedOldBitmap != nullptr)
{
SelectObject(m_derivedDc, m_derivedOldBitmap);
m_derivedOldBitmap = nullptr;
}
DeleteDC(m_derivedDc);
m_derivedDc = nullptr;
}
if (m_derivedBitmap != nullptr)
{
DeleteObject(m_derivedBitmap);
m_derivedBitmap = nullptr;
}
m_derivedBits = nullptr;
m_derivedW = 0;
m_derivedH = 0;
}
bool PlotView::RenderDerivedCache()
{
m_derivedValid = false;
if (m_theme == nullptr || m_fonts == nullptr || m_lanes.empty())
{
return false;
}
const int width = static_cast<int>(std::ceil(m_area.X + m_area.Width));
const int height = static_cast<int>(std::ceil(m_area.Y + m_area.Height));
if (!EnsureDerivedCache(width, height))
{
return false;
}
const std::size_t bytes = static_cast<std::size_t>(width) * static_cast<std::size_t>(height) * 4u;
if (!m_derivedStripActive)
{
// 整块重画:先把全部像素清成透明。
std::memset(m_derivedBits, 0, bytes);
}
else
{
// 补条带:只把这条竖带清成透明(其余像素是滚动过来的有效内容)。
const int x0 = std::max(0, static_cast<int>(std::floor(m_derivedStripX0)));
const int x1 = std::min(width, static_cast<int>(std::ceil(m_derivedStripX1)));
for (int y = 0; y < height; ++y)
{
unsigned char* row = static_cast<unsigned char*>(m_derivedBits) +
(static_cast<std::size_t>(y) * static_cast<std::size_t>(width)) * 4u;
if (x1 > x0)
{
std::memset(row + static_cast<std::size_t>(x0) * 4u, 0,
static_cast<std::size_t>(x1 - x0) * 4u);
}
}
}
{
gd::Graphics g(m_derivedDc);
g.SetSmoothingMode(gd::SmoothingModeAntiAlias);
g.SetPixelOffsetMode(gd::PixelOffsetModeHalf);
if (m_derivedStripActive)
{
// ⚠️ 绘制期的横向裁剪(`PaintDerivedCurvesFor` 只做 `intersect`,不会覆盖这一层)。
g.SetClip(gd::RectF(m_derivedStripX0, 0.0f, std::max(0.0f, m_derivedStripX1 - m_derivedStripX0),
static_cast<float>(height)),
gd::CombineModeIntersect);
}
for (std::size_t index = 0; index < m_lanes.size(); ++index)
{
PaintDerivedCurvesFor(g, static_cast<int>(index), m_lanes[index], false);
}
}
m_derivedContentKey = DerivedOverlayKey();
m_derivedViewMin = Tab().ViewMin();
m_derivedViewMax = Tab().ViewMax();
m_derivedValid = true;
return true;
}
bool PlotView::BlitDerivedCache(gd::Graphics& g)
{
if (!m_derivedValid || m_derivedDc == nullptr || m_plotRect.Width < 1.0f)
{
return false;
}
void* proc = ResolveAlphaBlend();
if (proc == nullptr)
{
return false;
}
using AlphaBlendFn = BOOL(WINAPI*)(HDC, int, int, int, int, HDC, int, int, int, int, BLENDFUNCTION);
AlphaBlendFn blend = reinterpret_cast<AlphaBlendFn>(proc);
HDC target = g.GetHDC();
if (target == nullptr)
{
return false;
}
// 缓存位图原点 = 客户区 (0,0):源与目标坐标相同。只贴绘图区(曲线被夹在网格内)。
const int x = std::max(0, static_cast<int>(std::floor(m_plotRect.X)));
const int y = std::max(0, static_cast<int>(std::floor(m_plotRect.Y)));
const int w = std::min(m_derivedW - x, static_cast<int>(std::ceil(m_plotRect.Width)));
const int h = std::min(m_derivedH - y, static_cast<int>(std::ceil(m_plotRect.Height)));
if (w <= 0 || h <= 0)
{
g.ReleaseHDC(target);
return false;
}
BLENDFUNCTION fn = {};
fn.BlendOp = AC_SRC_OVER;
fn.SourceConstantAlpha = 255;
fn.AlphaFormat = AC_SRC_ALPHA;
const BOOL done = blend(target, x, y, w, h, m_derivedDc, x, y, w, h, fn);
g.ReleaseHDC(target);
return done != FALSE;
}
void PlotView::PaintDerivedOverlayLayer(gd::Graphics& g)
{
if (m_loadingBusy || m_lanes.empty())
{
return; // 加载遮罩期间不花时间在派生曲线上(本来也看不见)
}
// 没有任何派生功能开启时直接跳过(并释放缓存,避免白占内存)。
bool anyFeature = false;
for (const Lane& lane : Tab().Lanes())
{
if (lane.edgeCenter || lane.meanFilter || lane.weakPeak)
{
anyFeature = true;
break;
}
}
if (!anyFeature)
{
m_derivedValid = false;
return;
}
const std::uint64_t contentKey = DerivedOverlayKey();
const double viewMin = Tab().ViewMin();
const double viewMax = Tab().ViewMax();
const double span = viewMax - viewMin;
bool needRender = true;
bool rolled = false;
m_derivedStripActive = false;
if (m_derivedValid && m_derivedContentKey == contentKey && span > 0.0)
{
const double cachedSpan = m_derivedViewMax - m_derivedViewMin;
const double sameSpanTolerance = std::max(1.0e-12, std::fabs(span) * 1.0e-9);
if (std::fabs(cachedSpan - span) <= sameSpanTolerance &&
m_derivedViewMin == viewMin && m_derivedViewMax == viewMax)
{
needRender = false; // 完全同源:只贴图
}
else if (std::fabs(cachedSpan - span) <= sameSpanTolerance && m_plotRect.Width > 1.0f)
{
// 纯平移:像素整体横移,只重画新露出的竖带(与宽缓冲区的滚动同构)。
const double pxPerTime = static_cast<double>(m_plotRect.Width) / span;
const int shift = static_cast<int>(
std::lround((m_derivedViewMin - viewMin) * pxPerTime));
const int width = m_derivedW;
if (shift != 0 && std::abs(shift) < width)
{
const std::size_t rowBytes = static_cast<std::size_t>(width) * 4u;
const std::size_t moveBytes =
rowBytes - static_cast<std::size_t>(std::abs(shift)) * 4u;
unsigned char* bits = static_cast<unsigned char*>(m_derivedBits);
if (bits != nullptr && moveBytes > 0)
{
for (int y = 0; y < m_derivedH; ++y)
{
unsigned char* row = bits + static_cast<std::size_t>(y) * rowBytes;
if (shift > 0)
{
// 内容右移:从本行右端往前搬(`memmove` 自带重叠安全)。
std::memmove(row + static_cast<std::size_t>(shift) * 4u, row, moveBytes);
}
else
{
// 内容左移:源在本行右侧 —— 目标**不能**越过行首(否则越界)。
std::memmove(row, row + static_cast<std::size_t>(-shift) * 4u, moveBytes);
}
}
}
const float plotLeft = m_plotRect.X;
const float plotRight = m_plotRect.X + m_plotRect.Width;
if (shift > 0)
{
m_derivedStripX0 = plotLeft;
m_derivedStripX1 = std::min(plotRight, plotLeft + static_cast<float>(shift) + 1.0f);
}
else
{
m_derivedStripX0 = std::max(plotLeft, plotRight + static_cast<float>(shift) - 1.0f);
m_derivedStripX1 = plotRight;
}
if (m_derivedStripX1 > m_derivedStripX0 + 0.5f)
{
m_derivedStripActive = true;
rolled = true;
}
}
needRender = true;
}
}
if (needRender)
{
if (!RenderDerivedCache())
{
m_derivedStripActive = false;
return;
}
}
m_derivedStripActive = false;
if (!BlitDerivedCache(g))
{
// 无法 AlphaBlend:退回逐条曲线直接绘制(正确性优先,代价高但极少发生)。
if (!rolled)
{
m_derivedValid = false;
}
for (std::size_t index = 0; index < m_lanes.size(); ++index)
{
PaintLaneDerivedOverlays(g, static_cast<int>(index), m_lanes[index]);
}
}
}
bool PlotView::EnsureLaneLayer(LaneLayer& layer, int width, int height)
{
if (width <= 0 || height <= 0)
+10 -9
View File
@@ -227,7 +227,7 @@ namespace jdi
}());
/** @brief 全局「当前主题」指针(由宿主设置,见 `SetCurrentTheme`)。 */
const Theme* g_currentTheme = &kSolarizedLight;
const Theme* g_currentTheme = &kLight;
}
double SrgbLuminance(std::uint32_t argb)
@@ -347,15 +347,16 @@ namespace jdi
{
switch (kind)
{
case ThemeKind::LIGHT:
return kLight;
case ThemeKind::DARK:
return kDark;
case ThemeKind::SOLARIZED_DARK:
return kSolarizedDark;
case ThemeKind::SOLARIZED_LIGHT:
default:
return kSolarizedLight;
case ThemeKind::LIGHT:
default:
// 默认主题 = Light(用户 2026-11-02 要求;旧默认是 Solarised Light)。
return kLight;
}
}
@@ -363,26 +364,26 @@ namespace jdi
{
switch (kind)
{
case ThemeKind::LIGHT:
return L"Light";
case ThemeKind::DARK:
return L"Dark";
case ThemeKind::SOLARIZED_DARK:
return L"Solarised Dark";
case ThemeKind::SOLARIZED_LIGHT:
default:
return L"Solarised Light";
case ThemeKind::LIGHT:
default:
return L"Light";
}
}
const Theme& CurrentTheme()
{
return (g_currentTheme != nullptr) ? *g_currentTheme : kSolarizedLight;
return (g_currentTheme != nullptr) ? *g_currentTheme : kLight;
}
void SetCurrentTheme(const Theme* theme)
{
g_currentTheme = (theme != nullptr) ? theme : &kSolarizedLight;
g_currentTheme = (theme != nullptr) ? theme : &kLight;
}
const std::uint32_t* SignalPalette(int& count)
@@ -324,6 +324,19 @@ namespace jdi
*/
SignalStyle* StyleOf(const SignalRef& ref);
/**
* @brief 当前是否存在**样式编辑目标**(选中信号且其样式可解析)。
*
* ⚠️ 样式区的色块 / 样式 / 调整 / 线宽 / 透明度这些控件的矩形是**无条件**由
* `LayoutStyleControls()` 算出来的(布局与"是否有目标"无关)。若不在使用处按本判据
* 门控,就会出现用户 2026-11-02 报告的现象:什么都没选中时,打开「显示布局边界」
* 能看到那一排控件的框,鼠标移到「系数/偏移 | 镜像 | 绝对值」上还会变成手型。
*
* 使用处:`BuildBoxes`(几何上报)、`QueryCursor`(手型光标)。
* 绘制与命中早已按 `StyleOf(SelectedRef()) != nullptr` 门控,这里只是补齐另外两处。
*/
bool HasStyleTarget() const;
/**
* @brief 获取当前选中信号(跨标签页共享,因此切换视图后选中项保持一致)。
* @return 信号引用。
+55 -31
View File
@@ -100,34 +100,46 @@ namespace jdi
boxes.Add(ScrollThumbRect(), 3);
// ── 样式区:五行 + 每行内部的色块/按钮/滑轨/数值 ──────────────────
for (std::size_t index = 0; index < m_styleRowRects.size(); ++index)
// ⚠️ **只在存在样式编辑目标时登记控件几何**(用户 2026-11-02 报告):
// `LayoutStyleControls()` 无条件算出这些矩形,而绘制在“没有目标”时只画占位文字。
// 旧实现照样上报 ⇒ 空面板上打开「显示布局边界」会看到一整排并不存在的控件框。
const bool hasStyleTarget = HasStyleTarget();
if (hasStyleTarget)
{
boxes.Add(m_styleRowRects[index], 2);
for (std::size_t index = 0; index < m_styleRowRects.size(); ++index)
{
boxes.Add(m_styleRowRects[index], 2);
}
for (const gd::RectF& swatch : m_colorRects)
{
boxes.Add(swatch, 3);
}
boxes.Add(m_customChipRect, 3);
for (const gd::RectF& mode : m_modeRects)
{
boxes.Add(mode, 3);
}
for (const gd::RectF& adjust : m_adjustRects)
{
boxes.Add(adjust, 3);
}
boxes.Add(m_widthTrackRect, 3);
boxes.Add(m_widthValueRect, 3);
boxes.Add(m_widthResetRect, 3);
boxes.Add(m_opacityTrackRect, 3);
boxes.Add(m_opacityValueRect, 3);
boxes.Add(m_opacityResetRect, 3);
}
for (const gd::RectF& swatch : m_colorRects)
{
boxes.Add(swatch, 3);
}
boxes.Add(m_customChipRect, 3);
for (const gd::RectF& mode : m_modeRects)
{
boxes.Add(mode, 3);
}
for (const gd::RectF& adjust : m_adjustRects)
{
boxes.Add(adjust, 3);
}
boxes.Add(m_widthTrackRect, 3);
boxes.Add(m_widthValueRect, 3);
boxes.Add(m_widthResetRect, 3);
boxes.Add(m_opacityTrackRect, 3);
boxes.Add(m_opacityValueRect, 3);
boxes.Add(m_opacityResetRect, 3);
// ── 「目标」行(样式区顶部,展示当前选中信号) ────────────────────
// 这一行**无论是否选中**都会绘制(未选中时显示占位文本),所以本体总是登记;
// 但信号名 / GX 徽标只在实际有目标时存在。
boxes.Add(m_targetRowRect, 2);
boxes.Add(m_targetNameRect, 3);
boxes.Add(m_targetBadgeRect, 3);
if (hasStyleTarget)
{
boxes.Add(m_targetNameRect, 3);
boxes.Add(m_targetBadgeRect, 3);
}
// ── 列表里**当前可见**的行,以及每行内部的元素 ────────────────────
// 被滚出列表的行不登记,否则边界会糊到列表区之外。
@@ -382,14 +394,6 @@ namespace jdi
m_rowTop[index + 1] = m_rowTop[index] + m_rows[index].height;
}
// 行顶前缀和:`m_rowTop[i]` = 第 0..i-1 行高度之和(长度 = 行数 + 1)。
// 行集合与行高只在这里变化 ⇒ 在此重建是唯一权威的时机。
m_rowTop.assign(m_rows.size() + 1, 0.0f);
for (std::size_t index = 0; index < m_rows.size(); ++index)
{
m_rowTop[index + 1] = m_rowTop[index] + m_rows[index].height;
}
float totalShift = 0.0f;
for (const GroupAnim& anim : m_groupAnims)
{
@@ -623,6 +627,15 @@ namespace jdi
void SignalPanel::EnsureRows()
{
// ⚠️ 动画推进只依赖挂钟时间(`AdvanceGroupAnimations`),而驱动它的 `WM_TIMER` 是
// **最低优先级**的消息:绘图区若在自持刷新(例如边沿统计未完成时不断请求新帧),
// `WM_TIMER` 会被饿死 ⇒ 折起动画永远结束不了 ⇒ 组名三角已经显示“已收起”,
// 但行集合里仍保留着该组的信号行(可被点选)。用户 2026-11-02 报告的正是这个现象。
// 因此绘制 / 命中路径也顺手推进一步:动画是否结束只看时间,不依赖消息到达。
if (!m_groupAnims.empty())
{
AdvanceGroupAnimations();
}
if (m_rowsDirty)
{
BuildRows();
@@ -1453,6 +1466,17 @@ namespace jdi
return (m_dataSetWritable != nullptr) ? m_dataSetWritable->Style(ref) : nullptr;
}
bool SignalPanel::HasStyleTarget() const
{
// 与 `PaintStyleSection` / `OnLButtonDown` 的判据**完全同源**:选中信号且样式可解析。
if (m_tab == nullptr || m_dataSet == nullptr)
{
return false;
}
const SignalRef& selected = SelectedRef();
return selected.IsValid() && StyleOf(selected) != nullptr;
}
const SignalRef& SignalPanel::SelectedRef() const
{
static const SignalRef fallback;
+20 -14
View File
@@ -55,21 +55,27 @@ namespace jdi
// 样式区的可点击控件(「调整」行三个按钮、线宽/透明度数值框)也用手型光标。
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
// 样式区顶部「目标」行的信号名与 GX 标签:与信号列表行一致,用手型光标提示可交互。
UpdateTargetRowGeometry();
if ((m_targetNameRect.Width > 0.0f && UiPointInRect(m_targetNameRect, fx, fy)) ||
(m_targetBadgeRect.Width > 0.0f && UiPointInRect(m_targetBadgeRect, fx, fy)))
// ⚠️ 样式区控件只在**存在编辑目标**时才可点(用户 2026-11-02 报告:没有选中信号时,
// 鼠标掠过「系数/偏移 | 镜像 | 绝对值」仍会变成手型,而点了什么也不会发生)。
if (HasStyleTarget())
{
return LoadCursorW(nullptr, IDC_HAND);
}
if (UiPointInRect(m_adjustRects[kAdjustGain], fx, fy) ||
UiPointInRect(m_adjustRects[kAdjustMirror], fx, fy) ||
UiPointInRect(m_adjustRects[kAdjustAbsolute], fx, fy) || UiPointInRect(m_widthValueRect, fx, fy) ||
UiPointInRect(m_opacityValueRect, fx, fy) ||
(m_widthResetRect.Width > 0.0f && UiPointInRect(m_widthResetRect, fx, fy)) ||
(m_opacityResetRect.Width > 0.0f && UiPointInRect(m_opacityResetRect, fx, fy)))
{
return LoadCursorW(nullptr, IDC_HAND);
// 样式区顶部「目标」行的信号名与 GX 标签:与信号列表行一致,用手型光标提示可交互。
UpdateTargetRowGeometry();
if ((m_targetNameRect.Width > 0.0f && UiPointInRect(m_targetNameRect, fx, fy)) ||
(m_targetBadgeRect.Width > 0.0f && UiPointInRect(m_targetBadgeRect, fx, fy)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
if (UiPointInRect(m_adjustRects[kAdjustGain], fx, fy) ||
UiPointInRect(m_adjustRects[kAdjustMirror], fx, fy) ||
UiPointInRect(m_adjustRects[kAdjustAbsolute], fx, fy) ||
UiPointInRect(m_widthValueRect, fx, fy) ||
UiPointInRect(m_opacityValueRect, fx, fy) ||
(m_widthResetRect.Width > 0.0f && UiPointInRect(m_widthResetRect, fx, fy)) ||
(m_opacityResetRect.Width > 0.0f && UiPointInRect(m_opacityResetRect, fx, fy)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
}
return nullptr;
}
+31 -8
View File
@@ -42,9 +42,9 @@ namespace jdi
m_scale = scale;
}
/** @brief 更新这一格的显示状态(名称 / 激活 / 编辑态 / 关闭按钮几何)。 */
/** @brief 更新这一格的显示状态(名称 / 激活 / 编辑态 / 关闭按钮与文本几何)。 */
void SetState(std::wstring name, bool active, gd::RectF closeRect, bool editing,
const TextEdit* edit, gd::RectF editRect)
const TextEdit* edit, gd::RectF editRect, gd::RectF textRect)
{
const bool changed = (m_name != name) || (m_active != active) || (m_editing != editing);
m_name = std::move(name);
@@ -53,6 +53,7 @@ namespace jdi
m_editing = editing;
m_edit = edit;
m_editRect = editRect;
m_textRect = textRect;
if (changed)
{
Invalidate(Bounds());
@@ -111,8 +112,14 @@ namespace jdi
1.0f * m_scale, m_theme->accent);
}
const float textLeft = rect.X + kTabPadding * m_scale;
const float textWidth = m_closeRect.X - kCloseGap * m_scale - textLeft;
// ⚠️ 文本可用矩形由 `TabBar::ArrangeWidgets` **统一下发**(与排布同一份几何),
// 不能用控件自己的 `m_scale` 从 `m_closeRect` 反推:标签宽度恰好等于
// `2*pad + 文本 + gap + close`(没有任何余量),而控件缓存的 `m_scale`
// 一旦与排布时的不一致(DPI 切换),反推出来的宽度就会少几像素 ⇒
// `UiEllipsize` 把最后一个字符换成省略号。
const gd::RectF textRect = m_textRect;
const float textLeft = textRect.X;
const float textWidth = textRect.Width;
if (m_editing && m_edit != nullptr)
{
@@ -155,8 +162,7 @@ namespace jdi
bool m_active = false; /**< 是否激活。 */
bool m_editing = false; /**< 是否处于重命名编辑态。 */
const TextEdit* m_edit = nullptr; /**< 编辑模型(编辑态时非空)。 */
gd::RectF m_editRect; /**< 编辑框矩形。 */
gd::RectF m_closeRect; /**< 关闭按钮矩形。 */
gd::RectF m_editRect; /**< 编辑框矩形。 */ gd::RectF m_textRect; /**< 文本可用矩形(由 `TabBar` 下发)。 */ gd::RectF m_closeRect; /**< 关闭按钮矩形。 */
};
/** @brief 「+」新建按钮控件。 */
@@ -277,7 +283,19 @@ namespace jdi
void TabBar::SetScale(float scale)
{
m_scale = (scale > 0.1f) ? scale : 1.0f;
const float next = (scale > 0.1f) ? scale : 1.0f;
if (next == m_scale)
{
return;
}
m_scale = next;
// ⚠️⚠️ **缩放变化必须重建控件树**(用户 2026-11-02 报告:把窗口从 125% 显示器拖到
// 100% 显示器后,唯一的标签页文本变成省略号,双击重命名再取消才恢复):
// 每个 `TabItemWidget` 在 `Configure` 时缓存了当时的 `m_scale`,而它内部用这个缩放
// 反推“可用文本宽度”(`closeRect.X - kCloseGap*scale - (rect.X + kTabPadding*scale)`)。
// 标签宽度是按新缩放排的(恰好 = 2*pad + 文本 + gap + close,**没有任何余量**),
// 而控件仍用旧缩放扣边距 ⇒ 可用宽度比实测文本宽度小几像素 ⇒ `UiEllipsize` 吃掉末字。
RebuildWidgets();
}
void TabBar::SetTabs(const std::vector<std::wstring>& names)
@@ -448,9 +466,14 @@ namespace jdi
TabItemWidget* widget = static_cast<TabItemWidget*>(children[index].get());
const TabItem& tab = m_tabs[index];
const bool editing = (static_cast<int>(index) == m_editIndex);
// 文本可用矩形:与排布用同一份 `m_scale`,不再由控件自行反推。
const gd::RectF textRect(tab.rect.X + kTabPadding * m_scale, tab.rect.Y,
std::max(8.0f, tab.closeRect.X - kCloseGap * m_scale -
(tab.rect.X + kTabPadding * m_scale)),
tab.rect.Height);
widget->Arrange(tab.rect);
widget->SetState(tab.name, static_cast<int>(index) == m_activeIndex, tab.closeRect, editing,
editing ? &m_edit : nullptr, EditRect());
editing ? &m_edit : nullptr, EditRect(), textRect);
}
static_cast<TabAddWidget*>(children[m_tabs.size()].get())->Arrange(m_addRect);
static_cast<TabOverlayWidget*>(children[m_tabs.size() + 1].get())->Arrange(m_area);
+557
View File
@@ -8941,3 +8941,560 @@ rendercheck 8 个场景哈希与基线完全一致;make release / make debug
白改了一轮断言。
* ⚠️ PowerShell 里用 `'...'` 拼接含 `%d` / 引号 / 长中文的补丁串极易被截断(出现 `>>` 续行提示后
只能 Ctrl+C)。**补丁一律写成 `.git\xxx.ps1`(纯 ASCII)+ `%TEMP%\xxx.txt` 数据文件**。
---
## 2026-11-02 第十九轮(本机):288 信号卡顿根治 + 中心轴面板重做 + 标签页默认状态
### 1. 卡顿真因:**面板缓存键把"鼠标驱动的一切"都混了进去**
复现:`VERY_BIG_DATA.CSV` + `MF4-Record.MF4` 全部信号合并到**同一网格**(288 信号),
开「中心轴边沿计数」后拖动横向光标/移动鼠标,实测 **29.8ms/帧**(修复后 **6.6ms**)。
分解(`.preflight/bugcheck.exe` A5b):
| 场景 | 单帧 |
| --- | --- |
| 面板开 + 统计开 | 27.6ms |
| **面板关 + 统计开** | **4.1ms** |
| 面板关 + 统计关 | 6.6ms(噪声) |
⇒ 27.6−4.1 ≈ **23ms 全在面板绘制**,而**不是**统计扫描。三个叠加原因:
1. ⚠️⚠️ **中心轴面板高度没有上限**:`itemized` 旧分支按"每个勾选信号两行 + 每行 22px"
累加,288 个信号 ⇒ **面板高 9996px**(绘图区只有 1141px)。`PanelRectInsideLayer()`
因此永远为假 ⇒ **面板像素缓存一次都存不进去** ⇒ 每帧全量重画 288 行。
2. ⚠️⚠️ **`PanelGlobalKey` 混入鼠标驱动状态**:`m_hoverSignal` / `m_hoverLane` /
`m_hoverCursor` / `Tab().CursorTime()` / `EdgeRangeMin/Max` 等只要鼠标一动就变
⇒ `panelGlobalSame` 每帧为假 ⇒ 所有面板每帧重画。而 `PanelSlotKey` 里还会为**每个信号**
调一次 `EdgeCenterCountsFor`(含区间边界块扫描)⇒ 单帧 288 次查询。
3. 缓存命中路径里的**投影**(与面板同大的半透明抗锯齿圆角矩形,400×950px)每帧重画,
GDI+ 软件渲染实测 > 20ms。
**修法**:
* 新增 `PlotView::InputIsInteractive()`(正在拖拽、或鼠标在 `kEdgeScanInteractiveIdleMs`
= 140ms 内移动过)。**交互期间**:
- `PanelGlobalKey` 冻结全部鼠标驱动项(`m_hoverSignal` / `m_hoverLegend` / `m_hoverLane` /
`m_hoverCursor` / `m_cursorHover` / `CursorTime` / `EdgeRangeMin/Max`)与
`mouseDrivenOverlay`;`PanelSlotKey` 跳过整段边沿统计数字;
- 缓存命中路径**跳过投影**;`m_edgeScanBudget` 用 `kEdgeScanPairsPerFrameInteractive`(8 万,
静止时 40 万)。
- ⚠️ **`m_meanRowHoverLane/Ref` 必须例外**:它直接改变面板像素、且只在进出某一行时变 ——
一起冻结的话"鼠标移到条目栏上高亮"就永远不出现。
- 鼠标停下 140ms 后键自动恢复 ⇒ 面板重画一次显示最终数字(不会"停在统计中")。
* 统计预算 `kEdgeScanPairsPerFrame` **1,200,000 → 400,000**(中心轴每个采样对还要按
`均值点数 × 迭代次数` 算一次均值窗口 ⇒ 120 万对 ≈ 3 千万次浮点运算/帧)。
* **中心轴面板重做为可滚动列表**(见第 2 节)⇒ 面板高度被 `m_plotRect.Height` 夹住、
缓存能存进去、只画可见行。
### 2. 「中心轴边沿计数」面板重做(与「均值滤波」完全同风格)
用户要求:条目栏是**信号色叠底的折叠栏**、右端复选框代表"是否统计该信号"、栏下**同一行**
显示「上升沿 / 下降沿」、再下面两行滑块(均值点数 / 迭代次数,标签不带 X/N)、
数值可点击输入上限、右侧复位按钮、整个面板可滚动、**未勾选时条目栏不可展开**。
实现要点:
* `EdgePanelLayoutFor`:`listStyle = (meanFilter || centerAxis)` 取代原来的 `if (meanFilter)`;
`itemized` 现在**只剩** `weakPeak`。中心轴的内容 = 条目栏 + (勾选且未收起时)
**统计行 + 两行滑块**(统计行在 `rowStats`,滑块复用 `rowLevel*` / `rowIter*`)。
* 新增 `Lane::edgeCenterScroll`(+ `LaneCarry` 同步)+ `PlotView::SetLaneEdgeCenterScroll()`。
滚动偏移通过 `layout.scrollOffset` 自动进 `PanelSlotKey`(无需额外 mix)。
* 滑块最大值:**均值滤波 = 1**(用户 2026-10-30 要求不能为 0),**中心轴 = 0**
(0 = 不平滑,直接对原始值统计边沿,保持旧语义)。
* 交互复用 `PlotDragMode::MEAN_POINTS / MEAN_ITERATIONS / MEAN_SCROLL` + 新标志
`m_meanDragCenter`(拖拽/滚动目标面板)+ `m_editMeanCenter`(上限输入目标面板);
复位按钮、点击数值改上限、滚轮翻页、光标形状(条目栏/数值/复位=手型,滑块=左右箭头,
滚动条=上下箭头)全部共用同一段代码。
* 参数/勾选变化必须 `InvalidateEdgeScansFor(signal, true)`(定向作废,避免"改一个参数
其它信号也重算")。
### 3. 标签页
* ⚠️ **唯一标签页点 ✕ 不再弹警告**:`CloseTab` 在 `m_tabs.size() <= 1` 时直接 erase 再
`NewTab(true)` ⇒ 名字必然是「视图 1」(`m_tabs` 清空后 `NextTabName()` 取最小未占用编号)。
⚠️ erase 与 `NewTab` 之间**不要**碰 `m_panel` / `m_plot`(上下文仍指向已释放的 `TabConfig`)。
* **新建标签页的默认状态**(手动新建也走同一入口):`NewTab()` 里新增
`m_dataSet.SetSelected(SignalRef())` —— 选中状态是**全局共享**的,不清掉的话新页会带着
上一个页的选中信号 ⇒ 样式编辑区有自己的"目标"。清空后样式区回到「未选择信号」。
组全部折起由 `SetGroupsCollapsedByDefault(true)` + `SetAllCollapsed(true)` 保证(既有行为)。
### 4. 顺带修正
* `PanelGlobalKey` 里 `m_hoverCursor/m_cursorHover/m_hoverLane` 也纳入交互冻结(只影响叠加层)。
* 光标形状:`QueryCursor` 对 MEAN/CENTER 可滚动列表补上条目栏/数值/复位/滑块/滚动条的
手型与箭头(原来只有 PEAK 有)。
### 5. 验证
```
.preflight/bugcheck.exe 73/73
A1 空闲 0.7ms · A2 中键平移 2.6ms · A2b 左键选区拖动 0.86ms
A3 双光标 2.4~2.6ms · A4 自由标记光标 5.2ms
A5 中心轴拖动 6.5ms(修前 29.8ms)
A5b 分解:面板开 5.7ms / 面板关 4.9ms / 无光标 7.2ms;面板 460.5px(修前 9996px)
B 组名展开/收起(283 行):行集合 0 ↔ 283、签名自洽
B3 真实消息路径(WM_LBUTTONDOWN/UP + WM_TIMER + WM_PAINT):展开 90 帧 / 收起 95 帧
B5 快速连点 3 次 / 动画中途反向:三角与行集合始终一致
C 中心轴面板:未勾选条目栏点击无效 / 复选框启用 / 整行收起展开 /
拖滑块 90% → 45 点 / 点数值输入上限 100 生效(m_editMeanCenter 正确)/
复位 → 5 点且上限 50 / 滚轮滚动 60px / 内容超高时出现滚动条
D 唯一标签页关闭 → 「视图 1」+ 全折起 + 无选中;手动新建同样
E **用户复现场景**:两个文件合并成一个网格 + 自由标记(含跟随光标)开启后,
用真实消息路径点组名展开:109 帧、**平均 2.75ms/帧**、最坏 16.8ms、仅 1 帧超 16ms;
**每一帧**三角状态与行集合都一致;列表墨迹 7922(收起)→ 70037(展开)→ 7922(再收起)
.preflight/panelcheck.exe 71/71 markercheck.exe 13/13 ringcheck.exe 6/6
make release / make debug 零 warning
```
⚠️ **A5b 的取值方式**:面板开/关 + 统计开/关 四种组合各测 10 帧取平均。`panel closed` 仍保留
`edgeCenter = true`(统计照跑),这样才能把"面板绘制"与"统计推进"的代价分开。
### 6. 踩坑
* ⚠️⚠️ **`PaintEdgeCursor` 的"笨重"必须先分段测量**:本轮一开始把 30ms 归因于"统计扫描"
和"统计数字进缓存键",改了预算与键都不见效;直到做了「面板开/关」「统计开/关」的
A/B 才定位到**面板高度超界 ⇒ 像素缓存从未命中**。**判据要用"缓存是否真的被用上"
(`m_panelSlots[0].key != 0` + `PanelRectInsideLayer`),而不是"看起来像重画"**。
* ⚠️ 探针里模拟点击面板前**必须关掉其它实验性功能**:它们默认位置同样在绘图区右上角,
`TopPanelAtPoint` 只返回最上层那一个 ⇒ 命中全被另一个面板抢走(表现为"点了没反应")。
* ⚠️ 组名行的点击热区:`X = 行矩形 40%` 处是名称(BODY),`x = 40px` 附近是**复选框**
(`RowPart::CHECK`)—— 想测折叠必须点名称区。
* ⚠️ 「行集合签名是否稳定」不能作为断言:动画**结束那一帧**签名必然变化一次(动画标记位
从 5 → 0),这是正确的。要断言的是"动画结束后 `m_rowsSignature == RowsSignature()`"。
---
## 2026-11-02 第二十轮(本机):面板限高/裁剪/弱峰列表化 + 边沿统计"统计中…"根治 + 组名折起无法完成
> 用户复现的四组问题(`VERY_BIG_DATA.CSV` + `MF4-Record.MF4` 全选后合并成同一网格):
> ①「中心轴边沿计数」面板下方有**无意义空白**;② 勾选全部信号后滚动,**绘制超出面板**;
> ③ 启用「边沿计数」后来自 VERY_BIG_DATA 的信号**永远"统计中…"**;
> ④ 折起分组后**三角已收起但下属信号行仍在、仍可点选**(点第二个组时第一个组才真正收起);
> ⑤ 启用**尚未改造**的实验性功能后一切交互(光标/动画)都很卡。
### 1. ⭐⭐ 根本原因:**面板高度没有上限 ⇒ 面板像素缓存永远存不进去**
`itemized`(旧式逐条目)排布按「每勾选信号 2 行 + 每行 22 逻辑像素」累加,288 条信号时
面板高达 **13010px**(绘图区只有 846px)。`PanelRectInsideLayer()` 因此恒为假 ⇒
**面板像素缓存一次都没写进去** ⇒ 每帧整块重画(288 行 × 十来个 GDI+ 原语)。
「边沿计数」与(改造前的)「弱峰调整」都是这个排布,所以"只要开这两个功能就卡"。
**硬规则(新)**:**任何**实验性功能面板的高度都必须被绘图区夹住:
`budget = plotH - (标题 + pad + 底部区)`,`view = min(内容高, maxContent)`,
内容超出时右侧出现**滚动条**。`itemized` 排布已**整体删除**(布局 + 绘制 + 交互),
现在 5 个面板全部是「有高度上限 + 可滚动」的实现。
### 2. 四个面板的滚动偏移(`Lane`)
| 面板 | 偏移字段 | setter |
| --- | --- | --- |
| 均值滤波 | `meanFilterScroll` | `SetLaneMeanFilterScroll` |
| 中心轴边沿计数 | `edgeCenterScroll` | `SetLaneEdgeCenterScroll` |
| **弱峰调整(新)** | **`weakPeakScroll`** | **`SetLaneWeakPeakScroll`** |
| **边沿计数(新)** | **`edgeCountScroll`** | **`SetLaneEdgeCountScroll`** |
前三个是**列表式**(信号色叠底条目栏 + 右侧复选框 + 展开后的两行滑块);
「边沿计数」保留旧式两行排版,但同样有 `layout.content`(裁剪 + 滚动条 + 滚轮 / 拖动)。
### 3. ⚠️⚠️ 无按钮面板的底部留白(用户指出的"无意义空白")
旧高度算式**无条件**按 `标题 + pad + 内容 + pad + 按钮 + pad` 预留,而 `viewHeight`
又无条件减去 `kEdgePanelButtonHeight`;「中心轴边沿计数」没有「构建为新信号」按钮 ⇒
内容区下方白留了 **按钮高度 30 + pad 7 = 46 逻辑像素**(实测 55px @125%)。
现改为:
```
footer = hasButton ? (buttonHeight + pad) : 0
height = 标题 + pad + view + pad + footer
viewHeight = height - 标题 - 2*pad - footer // = view
budget = plotH - (标题 + pad + pad + footer)
```
### 4. ⚠️⚠️ "绘制超出面板" = **GDI 文字不受 GDI+ 裁剪约束**
`paintCounts`(上升沿 / 下降沿)用的是 `UiText`(`DrawTextW`)⇒ `g.SetClip(layout.content)`
对它**无效**,滚动时数字会画到标题栏 / 面板之外。现统一改 `UiTextClipped(..., layout.content)`。
**A/B 已证**:改回 `UiText` 后,标题栏区域像素差 **805px**(x1..241、y12..24)。
**硬规则**:凡"可滚动内容区"里的文字一律 `UiTextClipped`(含名称、标签、数值、统计、提示)。
`UiFillRoundRect` 的 GDI 快路径(≥20000px² 且不透明)同样不认裁剪 —— 条目栏宽 ≈ 420px,
面积 < 20000,目前安全;**宽度再放大时要复核**。
### 5. ⭐⭐ 「统计中…」永不更新 = **状态上限 64 < 信号数** + 预算分摊不公平
`EdgeScanFor` 的累积状态上限原本固定 `kEdgeScanStateLimit = 64`,
而面板会在同一帧为**每条信号**各创建一个状态(288 > 64)⇒ 新建时总淘汰队首(未完成的)
⇒ **没有任何状态能扫完** ⇒ 永远"统计中…"。
- 新增 `PlotView::EdgeScanStateLimit()`:`max(64, 信号总数 × 2 + 16)`(每信号最多两类状态)。
- **每帧配额去掉固定下限** `kEdgeScanPairsPerStateMin(60000)`:状态一多,下限会让最前面的
几个状态吃掉整帧预算,其余永远 0。现为 `budget / share`(至少 1),**`share` 只数「未完成」
状态**(否则完成状态仍占分母,长信号每帧只分到 1/300,实测要 20 分钟)。
- 实测:288 条信号(含 5 条 25.8M 采样)——短信号 1~2 帧内完成,长信号随后独享整帧预算。
### 6. ⭐⭐ 组名折起"三角已收起、行还在" = **`WM_TIMER` 被饿死**
`RequestEdgeScanFrame()` 原本在**每个未完成状态**里各 Post 一次
⇒ 288 条信号时单帧 **288 次 `PostMessage`** + 高帧率重绘 ⇒ 优先级更低的 `WM_TIMER`
拿不到执行机会 ⇒ `AdvanceGroupAnimations()` 永远不跑 ⇒ 折起动画永不结束 ⇒
行集合里的信号行永远不会被移除(三角却是从 `IsGroupCollapsed()` 直接画的,所以"看起来收起了")。
点击下一个组时,那次点击触发的绘制里又推进一次动画,于是"上一组这时才真正收起"。
修法两条(都要):
1. `RequestEdgeScanFrame()` **每帧最多一次**(`m_edgeScanRequestFrame == m_edgeScanFrame` 直接返回);
2. `SignalPanel::EnsureRows()` 开头:`if (!m_groupAnims.empty()) AdvanceGroupAnimations();`
—— 动画进度只依赖挂钟时间,**不应依赖消息能否按时到达**。
### 7. 「弱峰调整」改为列表式(与均值滤波同款)
- `WeakPeakItem` 新增 `collapsed`;面板 = 条目栏(三角 + 名称 + 复选框)+ 两行滑块
(调整幅度 ±400% / 迭代次数 1…自定上限)+ 右侧复位 + 底部「构建为新信号」。
- 交互全部复用 `MEAN_*` 拖拽模式(滚动用 `MEAN_SCROLL`),滑块拖动仍用
`PEAK_LEVEL / PEAK_ITERATIONS`,上限编辑仍用 `PEAK_ITERATIONS_MAX + m_peakEditCurrentMax`。
- ⚠️ `m_meanDragCenter`(bool) / `m_editMeanCenter`(bool) → **`m_meanDragKind` / `m_editMeanKind`**
(`EdgePanelKind`):现在有 MEAN / CENTER / PEAK / COUNT 四个写入目标。
- **鼠标指针**:列表式面板的条目栏只有**已勾选**时才显示手型;未勾选时保持箭头
(此时点击没有任何效果,用户 2026-11-02 明确要求)。
参数行的数值 / 复位 / 轨道也只在参数行展开(`rowLevelTracks.Width > 0`)时才给手型/左右箭头。
### 8. 验证(`.preflight/p20`,离屏 `SW_HIDE` + 合成消息)
合成数据:5 列 + 283 列两个 CSV → 两组 → 全部显示 → 合并成 1 个网格(288 信号)。
```
p20.exe 9/9
合并/信号数;COUNT 面板高 375 ≤ 绘图区 846 且 **像素缓存真的写进去了**(slot.key != 0);
288 个边沿统计全部 complete(states=288);
CENTER 面板底部留白 8.8px(= 1 个 pad,修前 55px);
组名折起后行集合无该组信号行;两组都折起后 signalRows == 0(真实 WM_LBUTTONDOWN/UP +
WM_TIMER(kScrollTimerId=3) + Render)
p20c.exe 11/11
PEAK 面板限高/可滚动/缓存命中;勾选展开两行滑块、收起后内容缩小;滚动偏移生效;
MEAN 条目栏:已勾选→手型、未勾选→箭头;
**CENTER 滚动到统计行越过内容区顶部时,标题栏区域 0 像素变化**(A/B 关掉裁剪 ⇒ 805px);
288 信号 + 面板打开时空闲帧 avg 2.6ms
make release / make debug 零 warning
```
### 9. 踩坑
- ⚠️ PowerShell 5.1 按 ANSI 读 `.ps1` ⇒ **脚本里不能出现中文**(连 `'条目栏'` 这种
单引号短串都会把解析器带崩,报"字符串缺少终止符")。断言数据放 UTF-8 文件、脚本纯 ASCII。
- ⚠️ `SetLaneMeanFilter/SetLaneWeakPeak/SetLaneEdgeCenterCount(lane, true)` 会**自动勾选
该网格第一个信号** ⇒ 断言"面板初始全未勾选"必然失败;要测未勾选行请用第 2 行。
- ⚠️ 面板圆角外侧的 1~2px 是"面板 AA 边缘 vs 缓存贴图(圆角裁剪)"的合法差异,
取像素做 A/B 时要**避开圆角 8px**,否则会误报 7 个像素。
- ⚠️ `git diff --numstat` 出现整文件级别的增删时要先怀疑行尾/BOM 被 PowerShell 改掉;
本轮所有写回都用 `[System.IO.File]::WriteAllText(... UTF8Encoding($false))`。
---
## 2026-11-02 第二十一轮(本机):预览曲线按面板高亮 + 统计数字"卡在统计中" + 中心轴面板改表格 + 默认主题 Light + 标签页 DPI + 布局边界幽灵元素
> 六件事,用户逐条报告。全部用离屏探针验证(`.preflight/p21`、`p22`、`p23`,`SW_HIDE` + 合成消息,
> 不占用鼠标/键盘/屏幕);`p20`(第二十轮的探针)回归全绿。
### 1. ⭐⭐ 预览曲线悬停高亮:只认「鼠标所在的那个面板」
现象:只开「弱峰调整 + 中心轴边沿计数」时,悬停信号条目栏**毫无反应**;三个实验性功能全开时,
悬停**任何一个**面板都只看到「均值滤波」那条曲线被高亮。
根因(`PaintLaneDerivedOverlays`):三条曲线的绘制分支里**只有 MEAN 分支传了 `highlight`**,
CENTER 分支压根没传(默认 false);而悬停状态只记录了 `(lane, ref)`,**没有记录面板种类** ——
同一条信号在三个面板里各有一行,无法区分"该高亮哪一条"。
修法:
* 新增 `m_meanRowHoverKind`(`EdgePanelKind`),`OnMouseMove` 与 `(lane, ref)` 一起维护;
* 每条曲线的高亮条件 = `悬停行 == 本行 && 悬停面板种类 == 本曲线的面板种类`;
* 条目栏的强调色叠加同样加上 `m_meanRowHoverKind == kind`(否则悬停一个面板会把其它面板的
同名行一起点亮);
* 面板缓存键(`PanelGlobalKey`)混入悬停种类(它直接改变面板像素)。
### 2. ⭐⭐ 统计数字"松手后永远停在统计中…"
现象:按住横向光标快速拖动、并在**鼠标继续快速移动时**松开左键 ⇒ 面板一直显示"统计中…",
直到鼠标移到面板上或做别的操作才更新(中心轴面板拖参数滑块后松手同理)。
根因:交互期间(拖拽 / 鼠标 140ms 内移动过)面板缓存键被**冻结**(`InputIsInteractive()`),
面板直接贴上一帧像素 —— 这是第十九轮为"拖动跟手"刻意做的。但**冻结结束后没有任何重绘请求**:
鼠标停了就没有 `WM_MOUSEMOVE`,统计也早已完成(不会再 `RequestEdgeScanFrame`)⇒
最后一次绘制仍是冻结帧,界面上永远停在旧文字。
修法(补一帧):
* `PanelGlobalKey()` 里记录 `m_panelKeysFrozen = interactivePanelKey`(`mutable`);
* 新增公开查询 `PlotView::PanelRefreshPending()`;
* `Application::Render` 末尾:`PanelRefreshPending()` 为真时也保持 `kScrollTimerId` 定时器,
`WM_TIMER` 走既有的"只失效绘图区"分支 ⇒ 冻结窗口结束后的下一帧按最新数字重画面板,
之后 `m_panelKeysFrozen` 变假、定时器自动停。
### 3. 「中心轴边沿计数」面板改为**表格**
表头固定不滚动:**信号名 | 上升沿计数 | 下降沿计数**;下面每条可见信号一行,行底色 = 该信号的
配色(未勾选的行用普通底色 + 空复选框,表示"尚未参与统计"),三列与表头严格对齐;内容超高时
右侧出现滚动条;单击行(非复选框)展开该信号自己的两行参数滑块(均值点数 / 迭代次数)。
实现要点:
* `EdgePanelLayout` 新增 `tableHeader` + `tableColumns[3]`(`TableColumn{left,width}`);
* `EdgePanelLayoutFor` 新增 `centerTable` 分支(CENTER 不再走 `listStyle`);
* 新增 `EdgeCenterTableHeader(col)` / `EdgeCountCellText(value, complete)`(单元格只写数字,
未算完时追加 `…`);
* `Lane::EnsureEdgeCenterItem` 新建条目时 **`collapsed = true`**(表格行默认收起参数滑块);
* `PanelSlotKey` 的 CENTER 分支**不能再按 `rowStats` 门控**(表格没有 rowStats 了)——
改为"凡 `FindEdgeCenterItem != nullptr` 的行都把 (rising, falling, complete) 混进键",
否则数字变化不会让键变化、面板永远停在"统计中…";
* 表格复用原有的 `rowHeaders/rowChecks/rowTriangles/rowNameRects/rowLevel*/rowIter*`,
因此**交互代码不用改**(勾选切换、整行展开收起、滑块拖动、滚动条 / 滚轮全部沿用)。
### 4. 默认主题改为 **Light**
`Application::m_themeKind = ThemeKind::LIGHT`;`ThemeFor()` 的 `default`、`ThemeName()` 的
`default`、`CurrentTheme()` / `SetCurrentTheme(nullptr)` 的兜底也一并改为 `kLight`
(避免"宿主未设置时回落到 Solarised Light"的不一致)。
### 5. 标签页文本在 DPI 切换后被省略
现象:主屏 125% 打开,拖到 100% 副屏后「视图 1」变成「视图…」,双击改名再取消就恢复正常。
根因:标签宽度恰好等于 `2*pad + 文本 + gap + close`(**没有任何余量**),而
`TabItemWidget` 在 `Configure({fonts, theme, scale})` 时**缓存了当时的 scale**,绘制时用它
从 `closeRect` 反推"可用文本宽度"。`TabBar::SetScale` 只改自己的 `m_scale`、**不重建控件树**
⇒ DPI 切换后控件仍用旧缩放扣边距(`14*1.25` vs `14*1.0`、`6*1.25` vs `6*1.0`)⇒
可用宽度比实测文本窄约 4.5px ⇒ `UiEllipsize` 吃掉末字。双击改名会走到重建路径,于是"又好了"。
修法(两层):
* `TabBar::SetScale` 在缩放**真的变化**时 `RebuildWidgets()`(与 `Toolbar::SetScale` 的
`m_treeDirty = true` 同一思路);
* 更根本的一层:**文本可用矩形改由 `TabBar::ArrangeWidgets` 统一下发**
(`TabItemWidget::SetState(..., textRect)`),绘制侧不再自己反推 —— 几何单一来源,
以后不会再被"缩放/取整"这类差异咬到。
### 6. 布局边界里的"幽灵元素"
* ⭐ **样式区控件在未选中信号时也上报几何、也接受光标**:`LayoutStyleControls()` 无条件算出
色块/样式/调整/线宽/透明度等矩形,而绘制在"没有目标"时只画占位文字。
`OnLButtonDown` 与 `OnMouseMove` 早已按 `StyleOf(SelectedRef())` 门控,
但 **`BuildBoxes` 与 `QueryCursor` 没有** ⇒ 打开「显示布局边界」能看到整排控件框,
鼠标掠过「系数/偏移 | 镜像 | 绝对值」还会变手型。
修法:新增 `SignalPanel::HasStyleTarget()`(与绘制/命中同一判据),
`BuildBoxes` 只在有目标时登记这些控件(「目标」行本体总是登记),
`QueryCursor` 同样只在有目标时给手型。
* ⭐ **关闭整个分组后残留大量边界框**:`Paint` 在 `m_lanes.empty()` 时直接 return,
`PaintLaneLegend / PaintCursor / PaintFreeMarker` 都不跑 ⇒
`m_legendHits / m_cursorLabelRects / m_cursorSpanRect / m_freeMarkerHits / m_panelSlots`
里的**旧矩形**一直留着(布局边界继续画、命中测试也会命中已不存在的元素)。
修法:空分支里显式清空这些几何并清 `m_panelOrder`。
* 浮动面板之上的叠加层(滚动条 / 回到最上方 / 内联输入框)以前**无条件上报**矩形
(即使没画)⇒ 现在按 `m_scrollBarVisible` / `BackToTopVisible()` / `m_editTarget != NONE`
门控。
### 7. 验证
```
p21.exe 9/9 无幽灵样式区框、空样式区无手型;选中信号后框与手型恢复;
关闭分组后 lanes=0、legendHits=0、绘图区内只剩 4 个真实元素框
p22.exe 14/14 默认主题 = Light;表格表头存在/列不重叠/内容在表头之下;
行底色 = 信号配色;交互帧冻结 ⇒ 空闲窗口后自动解冻(宿主补帧);
悬停 CENTER 行高亮中心轴曲线且种类记为 CENTER;悬停 PEAK 面板记为 PEAK;
**DPI 往返(96→120→96)后标签文本墨迹宽度 38 vs 实测 40(A/B 去掉重建 ⇒ 25)**
p23.exe 7/7 60 信号:表格限高/可滚动/像素缓存命中;勾选框切换统计;整行展开收起;
展开后出现滑块轨道;内容滚动生效
p20 / p20c / p20d 全绿(第二十轮的面板限高 / 弱峰列表 / 统计推进 / 组名折起)
make release / make debug 零 warning;Release 813,568 B(< 1MB 红线)
```
### 8. 踩坑
* ⚠️ 用 `#define private public` 的离屏探针里,"读像素来判断文字是否被省略"必须**与同一 DPI
下实测的文本宽度比较**:跨 DPI 比较字号本来就不同(本机主屏 125%,进程启动即 120 DPI)。
* ⚠️ 探针里两个面板默认都叠在右上角 ⇒ `TopPanelAtPoint` 只返回最上层那个,
要验证"某个面板的悬停"必须先把另一块的 `panel.x/y` 挪开。
* ⚠️ 改完 `TabBar` 的 `SetState` 签名要同步 `ArrangeWidgets` 唯一调用点;
探针 A/B 时**先确认 `make` 真的重编了**(过滤输出只看 error 会漏掉"没重编")。
---
## 2026-11-02 第二十二轮(本机):表格改造归位(边沿计数表格 / 中心轴回列表)+ 统计面板"悬停时永不更新" + 加载被统计阻塞 + 统计提速
### 1. ⚠️ 上轮把表格改造放错了面板(用户指出)
上一轮误把「表格(信号名 / 上升沿计数 / 下降沿计数、行底色=信号配色、列对齐、
单击行展开参数)」做在了**中心轴边沿计数**上。本轮**按用户要求归位**:
| 面板 | 现在的布局 | 说明 |
| --- | --- | --- |
| **边沿计数(COUNT)** | **表格**(表头固定 + 每信号一行 + 内容可滚动) | 只有滚动条可交互;新增 `Lane::edgeCountScroll` |
| **中心轴边沿计数(CENTER)** | **恢复为可滚动分组列表**(条目栏 + 复选框 + 展开后「统计行 + 两行滑块」) | 与均值滤波/弱峰调整同风格;`rowStats` 恢复 |
| 均值滤波 / 弱峰调整 | 可滚动分组列表 | 不变 |
* `EdgePanelWidth` / `EdgePanelLayoutFor` / `PaintEdgeCursor` 里 `listStyle = (centerAxis || meanFilter || weakPeak)`、
`countTable = (kind == COUNT)`;`EdgeCenterTableHeader` → **`EdgeCountTableHeader`**。
* **`Lane::EnsureEdgeCenterItem` 恢复 `collapsed = false`**(上轮为了表格默认收起,现在列表式要默认展开)。
* `PanelSlotKey` / `BuildBoxes` 的 `tableHeader` / `tableColumns[3]` 现在只由 COUNT 填。
### 2. ⭐⭐ 统计面板"鼠标一直在信号上移动时永不更新"
现象(用户报):鼠标停在某条信号上方、在其能触发高亮提示的范围内**持续移动**,统计面板就一直不更新。
根因:第十九轮为了提高交互帧率,把**统计数字从面板缓存键里整段跳过**(`interactiveKey`)。
`InputIsInteractive()` 的定义是"正在拖拽 **或** 鼠标在 140ms 内移动过"——只要鼠标一直动,
它就**永远为真** ⇒ 键永远不含统计 ⇒ 面板永远贴旧像素。
修法(不再逐信号查询,也不会每帧重画上百行):
* `EdgeScanState` 推进/完成时自增 `m_edgeScanRevision`(`EdgeScanFor` 里 +3 处);
* `Paint` 开头把它转成 `m_panelStatsVersion`:**非交互帧立刻同步**,
**交互帧按 `kEdgeScanStatsRefreshFrames`(=4) 节流**;
* `PanelSlotKey` 对 COUNT / CENTER 只 `mix(m_panelStatsVersion)`(O(1)、零查询)——
统计推进 ⇒ 版本号变 ⇒ 面板重绘;没推进 ⇒ 键稳定 ⇒ 贴回缓存。
A/B:关掉"交互期同步" ⇒ 探针 `changes` 从 **29 掉到 0**(面板确实不再更新)。
### 3. ⭐⭐ 统计没算完时"无法加载文件"
现象(用户报):统计面板还在"统计中…"时点「导入文件」,无论文件大小都要等统计完成才能加载。
根因:`LoadFileAsync` 的主循环每 15ms `UpdateWindow` 触发整帧绘制,而 `Paint` 里
`PaintEdgeCursor` 会**推进统计预算 + 重绘上百行面板**(283 信号时单帧 20ms+)⇒
加载进度被统计抢走 CPU,表现为"必须先等统计算完"。
(另外加载完成后 `InvalidateDerivedCaches` 会清空累积状态,于是又开始重算 —— 这是预期行为。)
修法:新增 `PlotView::SetLoadingBusy(bool)`(`Application::LoadFileAsync` 进出各设一次):
* `Paint` 里统计预算与时间预算都置 0;
* `PaintEdgeCursor` 提前返回(加载遮罩本来盖住整个界面,面板看不见);
* `RequestEdgeScanFrame` 不再自请求(避免加载期间的重绘风暴)。
实测(283 信号 + 扫描进行中):小文件 `LoadFile` **22~31ms**。
### 4. ⭐⭐ 统计太慢(VERY_BIG_DATA)
根因:旧的"每帧采样对配额"在**普通计数**(1~2ns/对)下把一帧限得太死;而在**中心轴**
(每个采样对还要算 `points × iterations` 窗口均值,≈7 倍成本)下同样的配额又嫌太大。
一个数字无法同时适配两种成本。
修法:**按时间切**(`m_edgeScanDeadline`,QPC):扫描循环**每处理一个块**检查一次是否超时;
采样对配额只保留"公平分摊"作用(`budget / 未完成状态数`,状态完成后让出配额):
* 非交互帧时间预算 **6ms**,交互帧 **1.5ms**;
* 配额上限提高:`kEdgeScanPairsPerFrame` 8,000,000 / 交互 2,000,000。
实测(本机):
* 合成 300k×4:**1 帧**扫完(≈13ms 含整帧绘制)。
* **真实 `TestData/MF4-Record.MF4`(283 信号、合并成 1 个网格、同时开边沿计数 + 中心轴):
全部统计状态 1 帧(5.6~8ms)内完成**(上轮需要持续多帧且卡在"统计中")。
* 悬停 + 持续移动时的帧耗时:中位 **2.1~3.0ms**、p90 ≈ 5ms(283 信号,两个面板都开着)。
### 5. 验证(离屏探针,`SW_HIDE` + 合成消息,不占用鼠标/键盘/屏幕)
```
.preflight/p25.exe 12/12
A 鼠标持续移动时面板持续更新(changes=29,帧 2.2ms);A/B 关掉交互期同步 ⇒ 0
B 扫描进行中加载文件 22ms、加载忙标志正确清除
C 300k 采样 1 帧扫完,pairs/frame ≥ 60k(实测 300k)
D 283 信号:面板在悬停+移动时仍被缓存、帧中位 2.5ms
E 真实 MF4-Record:283 信号、COUNT 表格 + CENTER 列表都限高;
悬停+移动帧中位 2.1ms;全部统计 1 帧完成
.preflight/p20 9/9 · p20c 11/11 · p20d 5/5 · p21 9/9 · p22 14/14 · p23 8/8(含中心轴统计行断言)
make release / make debug 零 warning;Release 808,960 B
```
### 6. 踩坑
* ⚠️ `SetAllVisible(false)` + `SetGroupVisible(last, true)` 才是"只显示最后加载的那个分组";
直接 `SetGroupVisible(0, true)` 会把之前加载的分组一起显示(探针里"283 信号"变成 4 信号)。
* ⚠️ 统计重放很快收敛后,"悬停时必须更新"这类断言要用**确定性的版本号推进**来测,
否则会因"扫描早已完成"而假失败(真实场景的更新路径是同一条)。
* ⚠️ PowerShell 5.1 按 ANSI 读 `.ps1`:脚本里**不能出现中文**(连正则里带中文都会解析失败),
要把中文内容放进 UTF-8 数据文件再读取。
---
## 2026-11-02 第二十三轮(本机):悬停高亮回归修复 + 「极端工况」绘制管线性能改造
### 1. ⚠️ 悬停高亮"丢失"的根因与修复
现象(用户报):鼠标停在**带预览曲线的实验性功能面板的信号名条目栏**上,对应的预览曲线不再高亮。
根因:高亮被画在 `PaintLaneDerivedOverlays`(预览曲线绘制)内部,而这条绘制路径在
**命中静态场景缓存时根本不会被调用**(`SceneCacheMatches() && BlitSceneCache(g)` 直接
`paintOverlays(); return;`)。悬停态又不参与 `SceneContentHash`(参与的话每动一下鼠标就整幅重绘)
⇒ 缓存命中帧永远看不到高亮。旧探针之所以"能过",是因为它每帧都整幅重绘。
修复(结构性,不是补丁):**高亮一律画在贴图之后的前景**(与图例高亮同一套思路)。
* `PaintDerivedCurvesFor(g, lane, hoverOnly)`:`hoverOnly == false`(基底/缓存路径)只画普通曲线、
不带高亮;`hoverOnly == true` 只画悬停那一条并带光晕。
* 新函数 `PaintDerivedHoverHighlight(g)`,在 `paintOverlays()` 里紧跟 `PaintHoverHighlight`
调用(在面板之下、光标之上)。
* 高亮仍**必须按面板种类判定**(MEAN / CENTER / PEAK 各自只看自己面板的悬停行)。
### 2. ⭐⭐ 极端工况的性能改造(3 次 VERY_BIG_DATA + 全部信号并一条网格 + 全部实验性功能)
改造前实测(离屏探针 `p26f`,15 信号 × 3 功能 = 45 条预览曲线,1600×900):
| 场景 | 改造前 | 改造后 |
| --- | --- | --- |
| 中键拖动视界 | ~264 ms | **5.9 ms** |
| 双光标拖动 | ~187 ms | **20.9 ms** |
| 弱峰滑块拖动 | ~555 ms | **89 ms** |
| 改信号颜色 | ~2661 ms | **200 ms** |
| 悬停面板行 | ~225 ms | **38 ms** |
| 深放大后拖动视界 | ~207 ms | **23.8 ms** |
| 静止帧 | 7.8 ms | **0.6 ms** |
#### 2.1 预览曲线改「逐像素列包络带」(单条 15ms → 1.7ms)
旧实现把派生曲线用 `stride` 抽稀成折线后 `DrawLines`。视界覆盖百万采样时相邻抽稀点 y **整幅跳变**
(几乎每段都是竖直满幅),而 GDI+ 描边代价 ∝ **覆盖像素数**:实测 3200 顶点满幅锯齿折线
**128ms(抗锯齿)/ 31ms(无抗锯齿)**,同样画面的逐列 min/max 包络带一次 `FillPath` 只要 **1.9ms**。
现在稠密区间(可见采样数 > 像素列数)改用与波形基底同一套 `EmitEnvelopeBand`(逐列极值 +
线宽补偿),稀疏区间仍走逐点折线。附带好处:**不再漏掉尖峰**(等间隔抽样会整段跳过)。
列密度:中等密度 2px/列,极密(> 8 采样/px)4px/列(`kDerivedCurveColumnDivisor` /
`kDerivedCurveVeryDenseSamplesPerPixel`)—— 填充代价主要取决于边数与面积。
#### 2.2 ⭐ 预览曲线改「像素缓存层 + 平移滚动」(这是把拖动降到 6ms 的关键)
`PaintDerivedOverlayLayer`:45 条曲线画进一张带 alpha 的 32bpp DIB(与装饰层同一套配方),
正常帧只做一次 `AlphaBlend`(约 1~2ms)。三种情形:
* 内容与视界都相同 → 只贴图(静止 / 悬停 / 光标动画的帧全部走这条);
* 内容相同、跨度不变(纯平移)→ **像素整行 memmove 一个 dx + 只重画新露出的竖带**
(与宽缓冲区滚动同构)⇒ 拖动视界每帧代价与信号数无关;
* 其余(缩放 / 参数 / 样式变化)→ 整块重画,并记下渲染时的视界。
内容指纹 `DerivedOverlayKey()` = `SceneContentHash(true,true,true,true)` ⊕ `m_curveSerial`
(后台曲线到达时必须刷新);高亮状态**不进**指纹(前景绘制)。
⚠️ 滚动时 `memmove` 的方向必须分开写:内容左移(`shift<0`)时目标不能越过行首,否则越界崩溃
(本轮真实踩到)。
#### 2.3 ⚠️ 光标取值胶囊不再"每帧重算并发布 45 条曲线"
`CurveSpanFor` 新增 `computeIfMissing`。胶囊只要光标所在那两个采样,而缓存键含请求窗口、
窗口随光标每帧变化 ⇒ 每帧 45 次未命中 ⇒ 45 次同步计算 + `PublishCurve`(条目列表与线性搜索
同步膨胀)。实测占拖动光标单帧 **约 150ms**。现在胶囊只取"已覆盖"的曲线(绘制路径发布的窗口
带保护带、覆盖整个可见区间),未命中退到同 (信号, 种类) 的最新曲线,再无则本帧不标注。
#### 2.4 ⚠️⚠️ 样式补丁路径不再同步重建网格图层("改颜色卡到无法操作"的根因)
样式补丁(拖颜色/粗细/透明度)每帧 `QueueLaneRebuild` + `RenderPendingLaneRebuilds(1)`。
一个网格图层是 **2 倍宽 × 2 倍高**的整块重绘,15 条稠密信号实测 **2.2 秒**
(整幅重绘只要 ~0.4 秒)。现在:
* **不再同步重建**(只登记待重建;`LaneLayerHash` 含颜色/线宽,宽基底快路径会自动跳过它);
* 开启了派生功能时**直接不走补丁路径**(补丁会把缓存里已烘进去的曲线像素覆盖掉),
改为整幅重绘 —— 实测 2.2s → 0.2s。
### 3. 验证(离屏探针,`SW_HIDE` + 合成消息,不占用鼠标/键盘/屏幕)
```
.preflight/p27(新增,8/8)
静态帧命中场景缓存;预览曲线在静态帧上可见;**悬停面板行在缓存命中帧上仍高亮**
平移后的叠加层与"完全重画"的参考帧逐像素一致(diff=0)
缩放后 / 改颜色后同样逐像素一致(diff=0,说明滚动/重建都正确)
无功能时缓存被释放、重新开启后重建
.preflight/p20 9/9 · p21 9/9 · p22 14/14 · p23 8/8 · p25 12/12
make release / make debug 零 warning;Release 814,080 B
```
### 4. 踩坑(重要)
* ⚠️⚠️ **离屏探针必须把画面画进 DIB 段的 HDC**(`CreateDIBSection` + `CreateCompatibleDC`),
与真实后备缓冲区一致。画进 `gd::Bitmap` 时每次 `Graphics::GetHDC()`(`UiText` 每次都会调)
都要锁/刷新软件表面 ⇒ 实测**单次文字 2.5ms**,会把这个成本错误地算到"文字/光标"头上
(同一份代码换 DIB 后单次文字 0.04ms)。换成 DIB 后 `paintOverlays` 里 30 个取值标注
从 200ms 降到 2.7ms —— **之前所有"光标拖动很慢"的结论都是探针假象**。
* ⚠️ 探针里 `OnMouseMove(cursorX, cy)` 很容易命中信号曲线而变成 **MOVE_SIGNAL** 拖拽
(该模式每帧让场景缓存失效);测光标拖动必须先断言 `m_dragMode == CURSOR_MOVE`。
* ⚠️ 场景缓存命中帧不会再走预览曲线绘制路径 —— 任何"随鼠标变化"的叠加内容都必须画在
`paintOverlays()` 里,否则只会在整幅重绘的那一帧出现。
* ⚠️ PowerShell 5.1 的 `-replace`/多行字符串在含中文/引号时会把命令搞坏;改文件内容优先
用编辑工具或 `.ps1` + `[System.IO.File]::WriteAllText`。
+35
View File
@@ -805,6 +805,13 @@ namespace jdi
int amountPercent = 0; /**< 调整幅度(-400–400,默认 0 = 不改动)。 */
int iterations = 1; /**< 迭代次数 N(1 … `maxIterations`)。 */
int maxIterations = 10; /**< 「迭代次数 N」滑块的上限(用户可在面板上修改,恒 ≥ 1)。 */
/**
* @brief 参数行是否被**收起**(只保留信号色叠底的条目栏)。
*
* 「弱峰调整」面板自 2026-11-02 起与「均值滤波 / 中心轴边沿计数」同一套可滚动列表:
* 勾选后条目栏左侧出现展开三角,单击它切换收起 / 展开。未勾选时本字段无意义。
*/
bool collapsed = false;
};
/**
@@ -1067,6 +1074,34 @@ namespace jdi
*/
float meanFilterScroll = 0.0f;
/**
* @brief 「中心轴边沿计数」面板内容的滚动偏移(客户区像素,0 = 顶部)。
*
* 与 `meanFilterScroll` 完全同一套语义:面板自 2026-11-02 起改为**可滚动的分组列表**
*(信号色叠底条目栏 + 右侧复选框 + 展开后的统计行与两行参数滑块),
* 内容超出可视高度时右侧出现滚动条(否则上百个信号会把面板顶出绘图区)。
*/
float edgeCenterScroll = 0.0f;
/**
* @brief 「弱峰调整」面板内容的滚动偏移(客户区像素,0 = 顶部)。
*
* 与 `meanFilterScroll` / `edgeCenterScroll` 完全同一套语义:面板自 2026-11-02 起
* 改为**可滚动的分组列表**(信号色叠底条目栏 + 右侧复选框 + 展开后的两行滑块),
* 底部「构建为新信号」按钮固定不滚动。
*/
float weakPeakScroll = 0.0f;
/**
* @brief 「边沿计数」面板内容的滚动偏移(客户区像素,0 = 顶部)。
*
* 该面板仍是「每信号两行(名称 / 上升沿·下降沿)」的旧式排版,但**内容可以滚动**:
* 上百个信号合并到同一网格时内容高达上万像素,若不限高,面板会远超绘图区
* ⇒ 面板像素缓存一次都存不进去 ⇒ 每一帧都要整块重画(用户 2026-11-02 报告
* “开启该功能后所有交互都非常卡顿”)。
*/
float edgeCountScroll = 0.0f;
/**
* @brief 「自由标记」面板里被**收起**的信号分组(点击分组栏切换)。
*
+12
View File
@@ -142,6 +142,12 @@ namespace jdi
std::vector<SignalRef> freeMarkerCollapsedGroups;
/** @brief 「均值滤波」面板内容的滚动偏移(详见 `Lane::meanFilterScroll`)。 */
float meanFilterScroll = 0.0f;
/** @brief 「中心轴边沿计数」面板内容的滚动偏移(详见 `Lane::edgeCenterScroll`)。 */
float edgeCenterScroll = 0.0f;
/** @brief 「弱峰调整」面板内容的滚动偏移(详见 `Lane::weakPeakScroll`)。 */
float weakPeakScroll = 0.0f;
/** @brief 「边沿计数」面板内容的滚动偏移(详见 `Lane::edgeCountScroll`)。 */
float edgeCountScroll = 0.0f;
};
// 先记下每个信号原来所在网格的网格自身状态:
@@ -173,6 +179,9 @@ namespace jdi
carry.edgeCenter = lane.edgeCenter;
carry.edgeCenterItems = lane.edgeCenterItems;
carry.edgeCenterPanel = lane.edgeCenterPanel;
carry.edgeCenterScroll = lane.edgeCenterScroll;
carry.weakPeakScroll = lane.weakPeakScroll;
carry.edgeCountScroll = lane.edgeCountScroll;
carry.meanFilter = lane.meanFilter;
carry.meanFilterItems = lane.meanFilterItems;
carry.meanFilterPanel = lane.meanFilterPanel;
@@ -233,6 +242,9 @@ namespace jdi
lane.edgeCenterPanel.collapsed = carry.edgeCenterPanel.collapsed;
lane.edgeCenterPanel.x = carry.edgeCenterPanel.x;
lane.edgeCenterPanel.y = carry.edgeCenterPanel.y;
lane.edgeCenterScroll = carry.edgeCenterScroll;
lane.weakPeakScroll = carry.weakPeakScroll;
lane.edgeCountScroll = carry.edgeCountScroll;
lane.meanFilter = carry.meanFilter;
lane.meanFilterItems = carry.meanFilterItems;
lane.meanFilterPanel.open = carry.meanFilterPanel.open;