「调整」数值符号规则 + 边沿统计按帧推进(含首帧公平分摊)+ 浮动面板排版与弱峰复位控件

一、「调整」类数值的符号(SignalPanel)
1. FormatSignedNumber:0 不再显示 `+`(旧实现恒带符号 → `+0.00`),Y 轴偏移默认值显示 0.00。
2. FormatGainText:非 0 正值带 `+`、负值带 `-`、0 不带符号;仅用于非编辑态显示,
   进入编辑的初始文本仍为无符号纯数值。
3. 新增 FormatOffsetTimeText:`Δt:` 后按正负带符号(Δt:+5s 60ms / Δt:-5s 60ms / Δt:0s),
   打开面板 / CMD_OFFSET_TIME 提交 / CMD_OFFSET_RESET 三处统一走它。

二、边沿统计:按帧预算 + 每状态公平配额
(用户报告:按住横向光标不动时统计停在“统计中…”不更新;改中心轴参数回车后不动鼠标时停在部分值)
根因:预算按 GetTickCount64() 分桶,而系统时钟粒度约 15.6ms;RequestEdgeScanFrame 的
PostMessage 自持重绘把帧率拉高 ⇒ 同一 tick 内连画多帧却完全不推进。
1. Paint 开头 ++m_edgeScanFrame; m_edgeScanBudget = kEdgeScanPairsPerFrame;(按帧重置),
   删除 m_edgeScanTick。
2. 每个状态各自持有每帧配额(pumpedFrame/pumpedPairs):perState = max(60000, 1.2M/share),
   share = max(kEdgeScanFairShare=6, m_edgeScans.size())。
   注意:不能直接用 size() 分摊 —— 状态在同一帧内逐个创建,第一个那一刻 size()==1 会独占整额,
   同帧后面新建的状态全部被截断为 0 配额(即“只有第一条信号推进”)。
实测(合成 5 信号 × 200 万采样、同一网格):首帧 5 个状态全部推进;9 帧内全部扫完(期间不注入
任何鼠标消息);数值与独立参考实现逐位一致;阈值变化后就地重置、随后“停住不动”仍精确扫完。

三、浮动面板排版
1. 信号名宽度上限 kEdgePanelNameMaxWidth=150 逻辑像素:宽度计算与绘制都用 min(实测, 上限) +
   UiEllipsize(300 字符名字只比单字符宽 44px,旧实现要多 5100px)。
2. 统计信息两列等宽(新增 EdgeRisingText/EdgeFallingText/EdgeCountsPending):面板宽度按
   2×max(两列文本宽)+kEdgePanelStatColumnGap 预留,绘制时两列各占 (可用宽−间距)/2 ⇒ 多条信号的
   “上升沿/下降沿”列起点固定、严格垂直对齐。
3. 未完成时 rising 与 falling 两个数字后面都带 `…`(旧实现只在整行末尾追加一次,
   视觉上只跟在“下降沿”的数字后面)。

四、弱峰调整面板
1. 每行滑块右侧新增复位控件(复用还原图标绘制 PaintPanelResetIcon):悬停高亮 + IDC_HAND +
   一次性提示;点击恢复幅度 0 / 迭代 1 且 maxIterations 回到初始上限 10。
   EdgePanelLayout 新增 rowLevelResets/rowIterResets(并进 PanelSlotKey),两行按钮共用同一 X。
2. 迭代次数上限输入框的空内容提示改为“输入上限(当前N)”。
3. 滑块标签去掉多余的 %/N(kWeakPeakLevelLabel=调整幅度;新增 kWeakPeakIterationsSliderLabel=
   迭代次数;MEAN / CENTER 输入框的“迭代次数 N”保持不变)。

五、验证(全部离屏;进程/线程 BELOW_NORMAL、窗口 SW_HIDE、不注入真实鼠标键盘)
- 新增 .preflight/perf/panelcheck_main.cpp:46/46 通过(符号规则走真实菜单路径;按帧推进 /
  不饿死 / 精确性 / 自持驱动;两列等宽预留(含未完成时两处 …);名字上限;
  弱峰复位控件位置 / 对齐 / 点击恢复默认值与上限)。
- 既有 verify_main.cpp:47/47(另 2 条为本机 .preflight 未随远端同步的旧规则断言)。
- make release 零警告。

规则同步:Documents/agent_mem.md 新增 §9.26,§11.2 新增第 43–48 条硬约束,变更日志补一行。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-10-15 01:35:47 +08:00
1 parent 65f7625dce
commit fba40ee1a2
4 files changed
+564 -80

No files matched your search

+33 -5
View File
@@ -1234,6 +1234,14 @@ namespace jdi
std::vector<gd::RectF> rowLevelValues;
std::vector<gd::RectF> rowIterTracks;
std::vector<gd::RectF> rowIterValues;
/**
* @brief 「弱峰调整」每行滑块右侧的**复位按钮**矩形(与 `rowLevelTracks` 同序)。
*
* 点击把该行参数恢复为默认值(幅度 0 / 迭代次数 1 且上限回到初始上限)。
* 未勾选或非 PEAK 面板时对应元素为空矩形(数组也可能为空)。
*/
std::vector<gd::RectF> rowLevelResets;
std::vector<gd::RectF> rowIterResets;
std::vector<SignalRef> rowRefs;
};
@@ -1663,9 +1671,11 @@ namespace jdi
*
* ⚠️ **本函数绝不长时间阻塞 UI 线程**(千万级信号整段扫描实测 ≈115ms/条,
* 横向光标每一帧都会换阈值 → 拖动时每帧都要扫,5 条信号就是 ≈575ms/帧):
* 扫描按「逐块累积」进行,每个 tick(约一帧)只消耗 `kEdgeScanPairsPerTick`
* 个采样对的预算,未扫完时 `complete` 返回 false(界面显示上一个完整结果 + “…”)。
* 拖动横向光标(`EDGE_MOVE`)期间阈值每帧都变,推进等于白做功 → 不推进。
* 扫描按「逐块累积」进行,**每帧**只消耗 `kEdgeScanPairsPerFrame` 个采样对的预算
*(且**每个状态各自持有配额**,多条信号不会互相饿死),未扫完时 `complete` 返回 false
*(界面显示上一个完整结果 + “…”)。
* 阈值变化(横向光标拖动中)就地把累积状态重置(不新建条目),且当帧不展示部分值
*(`justReset`),避免“错误且不更新的数字”。
*
* @param [IN] file 信号所属文件。
* @param [IN] signal 目标信号。
@@ -1871,6 +1881,8 @@ namespace jdi
* 因此必须单独记住这个状态才能触发重绘与取消高亮。
*/
int m_hoverBadge = -1;
/** 「弱峰调整」每行滑块右侧的复位控件是否处于悬停态(用于“进入/离开”时只提示一次)。 */
bool m_peakResetHover = false;
int m_mouseX = 0; /**< 最近一次鼠标横坐标(用于悬停提示定位)。 */
int m_mouseY = 0; /**< 最近一次鼠标纵坐标(用于悬停提示定位)。 */
bool m_spanHover = false; /**< 鼠标是否悬停在双光标差值标签上。 */
@@ -1915,6 +1927,15 @@ namespace jdi
std::size_t falling = 0; /**< 已扫部分的下降沿总数。 */
bool complete = false; /**< 整条信号是否已扫完。 */
bool justReset = false; /**< 本 tick 刚因阈值/参数变化重新开始(不展示部分值)。 */
/**
* @brief 本状态**上一次推进所在的帧序号**与当时已消耗的配额。
*
* 每个状态**每帧各自持有配额**(而不是所有状态共享一个总额):旧实现下推进总是
* 从列表第一个状态扫起,5 条信号同一网格时只有第一条有进展、其余长期显示“统计中…”
*(用户报告“很多项都停在了统计中不更新”)。
*/
std::uint64_t pumpedFrame = 0; /**< 最近一次推进的帧序号(与 `m_edgeScanFrame` 比较)。 */
std::size_t pumpedPairs = 0; /**< 本帧已消耗的采样对配额(新帧开始时重置)。 */
/**
* @brief 中心轴扫描**就地计算**的一小段均值滤波曲线(源下标空间)。
*
@@ -1932,8 +1953,15 @@ namespace jdi
bool localValid = false; /**< 本地区间是否可用。 */
};
mutable std::vector<EdgeScanState> m_edgeScans; /**< 边沿统计累积状态(纯缓存)。 */
mutable std::size_t m_edgeScanBudget = 0; /**< 本 tick 剩余可扫描的采样对数量(限制单帧总开销)。 */
mutable std::uint64_t m_edgeScanTick = 0; /**< 最近一次重置扫描预算的 tick。 */
mutable std::size_t m_edgeScanBudget = 0; /**< 本帧剩余可扫描的采样对数量(跨状态共享,限制单帧总开销)。 */
/**
* @brief 帧序号(`Paint` 每帧递增)。
*
* ⚠️ 预算必须**按帧**重置,不能按 `GetTickCount64()` 分桶:系统时钟粒度约 15.6ms,
* 帧率一高(`PostMessage` 自持重绘会把它拉高)就会在同一 tick 内连画多帧而不推进,
* 用户看到的就是“按住光标不动时统计卡住、要动鼠标才更新”。
*/
mutable std::uint64_t m_edgeScanFrame = 0;
/** 诊断计数:中心轴本地曲线计算失败次数(内存不足等)。0 = 一切正常。 */
mutable std::size_t m_edgeScanLocalCurveFails = 0;
+358 -60
View File
@@ -73,7 +73,56 @@ namespace jdi
return gd::RectF(left, top, 0.0f, 0.0f);
}
return gd::RectF(left, top, right - left, bottom - top);
} const float kCursorSnapMinPixels = 6.0f; /**< 光标吸附采样点的最小采样间隔(逻辑像素)。 */
}
/**
* @brief 绘制「还原/重置」图标(逆时针回环箭头)。
*
* 与 `PopupMenu` 里「系数 / 偏移」二级面板标题栏右侧的还原图标**同一套笔画**
*(用户要求:“可以直接挪用信号『调整』功能中『系数/偏移』二级菜单内的复位控件”),
* 因此弱峰调整面板的复位控件与它视觉完全一致。不依赖字体或资源,任意 DPI 都清晰。
*
* @param [IN,OUT] g 绘图上下文。
* @param [IN] rect 图标外接矩形(正方形)。
* @param [IN] color 笔画颜色(0xAARRGGBB)。
* @param [IN] scale DPI 缩放比例。
*/
void PaintPanelResetIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t color,
float scale)
{
const float inset = 2.5f * scale;
gd::RectF box(rect.X + inset, rect.Y + inset, rect.Width - inset * 2.0f,
rect.Height - inset * 2.0f);
if (box.Width <= 1.0f || box.Height <= 1.0f)
{
return;
}
gd::Pen pen(gd::Color((color >> 24) & 0xFFu, (color >> 16) & 0xFFu, (color >> 8) & 0xFFu,
color & 0xFFu),
std::max(1.0f, 1.5f * scale));
pen.SetStartCap(gd::LineCapRound);
pen.SetEndCap(gd::LineCapRound);
// 缺口留在右上角(从 300° 到 20° 处断开),与常见的"重置/刷新"图标一致。
g.DrawArc(&pen, box.X, box.Y, box.Width, box.Height, 300.0f, 300.0f);
// 箭头:位于缺口末端(约 300° 方向),指向前进方向。
const float cx = box.X + box.Width * 0.5f;
const float cy = box.Y + box.Height * 0.5f;
const float radius = box.Width * 0.5f;
const float angle = 300.0f * 3.14159265f / 180.0f;
const float tipX = cx + std::cos(angle) * radius;
const float tipY = cy + std::sin(angle) * radius;
const float size = std::max(2.5f, 3.2f * scale);
gd::PointF arrow[3];
arrow[0] = gd::PointF(tipX, tipY);
arrow[1] = gd::PointF(tipX - size, tipY - size * 0.25f);
arrow[2] = gd::PointF(tipX - size * 0.25f, tipY - size);
gd::SolidBrush brush(gd::Color((color >> 24) & 0xFFu, (color >> 16) & 0xFFu,
(color >> 8) & 0xFFu, color & 0xFFu));
g.FillPolygon(&brush, arrow, 3);
}
const float kCursorSnapMinPixels = 6.0f; /**< 光标吸附采样点的最小采样间隔(逻辑像素)。 */
const float kLaneLabelPad = 6.0f; /**< 网格内侧标尺带的内边距。 */
const float kLegendHeight = 19.0f; /**< 图例条目高度(逻辑像素)。 */
const float kLegendPad = 5.0f; /**< 图例相对网格左上角的内缩。 */
@@ -87,6 +136,8 @@ namespace jdi
const float kEdgePanelTitleHeight = 26.0f; /**< 面板标题栏高度。 */
const float kEdgePanelRowHeight = 18.0f; /**< 面板内单行文本高度。 */
const float kEdgePanelPad = 7.0f; /**< 面板内边距。 */
const float kEdgePanelNameMaxWidth = 150.0f; /**< 面板内信号名的宽度上限(超过则省略;避免超长信号名把面板顶得很长)。 */
const float kEdgePanelStatColumnGap = 14.0f; /**< 「上升沿 / 下降沿」两列之间的间距(保证多信号的文字垂直对齐)。 */
const float kEdgePanelEditFieldWidth = 64.0f; /**< 数值框的最小宽度。 */
const float kEdgePanelButtonHeight = 30.0f; /**< 均值滤波面板底部按钮行高度。 */
const float kEdgePanelMeanRowHeight = 22.0f; /**< 逐条目面板中每条信号的名称行高度。 */
@@ -96,6 +147,15 @@ namespace jdi
const float kWeakPeakSliderRowHeight = 26.0f; /**< 弱峰调整面板中每个滑块行的高度。 */
const float kWeakPeakTrackHeight = 6.0f; /**< 弱峰调整滑块的轨道高度。 */
const float kWeakPeakTrackMinWidth = 120.0f; /**< 弱峰调整滑块轨道的最小宽度。 */
/**
* @brief 「弱峰调整」滑块行右侧**复位控件**的边长(逻辑像素)。
*
* 每行滑块都有一个复位按钮(与「系数/偏移」二级面板里的「还原」图标同一套绘制),
* 点击把该行的参数恢复为默认值(幅度 0 / 迭代次数 1 且上限回到初始上限)。
*/
const float kWeakPeakResetButtonSize = 16.0f;
/** 复位控件与其左侧数值列之间的间距(逻辑像素)。 */
const float kWeakPeakResetGap = 4.0f;
const int kWeakPeakIterationsMin = 1; /**< 迭代次数 N 的下限。 */
/** 迭代次数滑块「自定义上限」的允许范围(与算法内部的阶数夹取保持一致)。 */
const int kWeakPeakIterationsMaxLimit = 1000;
@@ -144,8 +204,10 @@ namespace jdi
* 整条信号的重算本来就追不上滑块速度,"冻结"与"每帧重排"观感相同,但后者拖慢前台。
*/
const std::size_t kHugeSignalPreviewSamples = 4000000ull;
const wchar_t* const kWeakPeakLevelLabel = L"调整幅度 %"; /**< 逐条目面板里「幅度」滑块行的标签。 */
const wchar_t* const kWeakPeakIterationsLabel = L"迭代次数 N"; /**< 逐条目面板里「迭代次数」滑块行的标签。 */
const wchar_t* const kWeakPeakLevelLabel = L"调整幅度"; /**< 逐条目面板里「幅度」滑块行的标签(用户要求:滑动条前不再单独显示 `%`)。 */
const wchar_t* const kWeakPeakIterationsLabel = L"迭代次数 N"; /**< MEAN / CENTER 面板里「迭代次数」输入框的标签。 */
/** 「弱峰调整」迭代次数滑块行的标签(用户要求:滑动条前不再单独显示 `N`)。 */
const wchar_t* const kWeakPeakIterationsSliderLabel = L"迭代次数";
const wchar_t* const kWeakPeakTitle = L"弱峰调整"; /**< 弱峰调整面板标题。 */
const wchar_t* const kBuildSignalLabel = L"构建为新信号"; /**< 逐条目面板底部按钮文案(均值滤波 / 弱峰调整共用)。 */
/**
@@ -167,27 +229,43 @@ namespace jdi
*/
const std::size_t kEdgeBlockSamples = 8192;
/**
* @brief 每个 tick(约一帧)允许「边沿计数」扫描的采样对总数。
* @brief 每一帧允许「边沿计数」扫描的采样对总数(跨全部累积状态共享)。
*
* 千万级信号整段扫描一次 ≈115ms(用户报告:5 条信号同一网格时单帧 ≈575ms,
* 界面完全失去响应)。改成预算推进后,单帧最多只花 ≈5ms 在统计上,
* 剩下的部分在后续帧里接着扫(未扫完时界面显示上一个完整结果 + “…”)。
*
* ⚠️ **必须按帧重置**(在 `Paint` 开头):旧实现按 `GetTickCount64()` 分桶,
* 而系统时钟粒度约 15.6ms —— 当帧率高于它时(`PostMessage` 自持重绘会把帧率拉高),
* 同一 tick 内的多帧**完全不推进**(用户报告:“按住横向光标不动时统计卡在统计中”,
* “回车确认参数后不移动鼠标就一直停在部分值”)。
*/
const std::size_t kEdgeScanPairsPerTick = 1200000;
const std::size_t kEdgeScanPairsPerFrame = 1200000;
/**
* @brief 「边沿计数」结果的显示文本。
* @param [IN] rising 上升沿数量。 @param [IN] falling 下降沿数量。
* @param [IN] complete 是否为该阈值的完整精确值。
* @return 面板/宽度测量共用的文本(未算完时带省略号,完全未开始时显示“统计中…”)。
* @brief 每个累积状态每帧的扫描配额下限(防止多个状态时前面的把预算吃光)。
*
* 过去所有状态共享一个“本 tick 总预算”,而推进总是从列表中的第一个状态开始
* —— 5 条信号时只有第一条有进展,其余 4 条长期显示“统计中…”(用户报告
* “很多项都停在了统计中不更新”)。现在每个状态各自持有每帧配额,互不饿死。
*/
std::wstring EdgeCountsText(std::size_t rising, std::size_t falling, bool complete)
const std::size_t kEdgeScanPairsPerStateMin = 60000;
/**
* @brief 计算每状态配额时假定的“最小并发状态数”。
*
* ⚠️ 不能直接用 `m_edgeScans.size()`:状态是在**同一帧内逐个创建**的,第一个状态的
* 那一刻 `size() == 1`,会拿走整额预算 → 同帧后面新建的状态全部被截断为 0 配额
*(首帧只有第一条信号推进)。按一个典型并发量(普通 + 中心轴 × 网格内信号数)
* 下限分摊,可保证同一帧里所有状态都得到推进。
*/
const std::size_t kEdgeScanFairShare = 6;
/**
* @brief 「边沿计数」结果里的**上升沿**部分文本。
* @param [IN] rising 上升沿数量。 @param [IN] complete 是否为完整精确值。
* @return 形如 "上升沿 123"(未算完时追加 " …")的文本。
*/
std::wstring EdgeRisingText(std::size_t rising, bool complete)
{
if (!complete && rising == 0 && falling == 0)
{
return L"统计中…";
}
std::wstring text = L"上升沿 " + std::to_wstring(rising) + L" 下降沿 " +
std::to_wstring(falling);
std::wstring text = L"上升沿 " + std::to_wstring(rising);
if (!complete)
{
text += L" …";
@@ -195,6 +273,48 @@ namespace jdi
return text;
}
/**
* @brief 「边沿计数」结果里的**下降沿**部分文本。
* @param [IN] falling 下降沿数量。 @param [IN] complete 是否为完整精确值。
* @return 形如 "下降沿 456"(未算完时追加 " …")的文本。
*/
std::wstring EdgeFallingText(std::size_t falling, bool complete)
{
std::wstring text = L"下降沿 " + std::to_wstring(falling);
if (!complete)
{
text += L" …";
}
return text;
}
/**
* @brief 「边沿计数」是否处于“完全未算出”状态(用于显示“统计中…”)。
*/
bool EdgeCountsPending(std::size_t rising, std::size_t falling, bool complete)
{
return (!complete && rising == 0 && falling == 0);
}
/**
* @brief 「边沿计数」结果的显示文本(合并成一行,用于宽度测量与兼容旧调用)。
*
* ⚠️ 未完成时**两个数字后面都要带 `…`**(旧实现只在整行末尾追加一个 `…`,
* 视觉上只跟在“下降沿”的数字后面,用户明确要求“为上升沿也加上这个尾缀”)。
* @param [IN] rising 上升沿数量。 @param [IN] falling 下降沿数量。
* @param [IN] complete 是否为该阈值的完整精确值。
* @return 面板/宽度测量共用的文本。
*/
[[maybe_unused]] std::wstring EdgeCountsText(std::size_t rising, std::size_t falling,
bool complete)
{
if (EdgeCountsPending(rising, falling, complete))
{
return L"统计中…";
}
return EdgeRisingText(rising, complete) + L" " + EdgeFallingText(falling, complete);
}
/**
* @brief 判断坐标是否命中「弱峰调整」的某条滑块轨道。
*
@@ -237,7 +357,7 @@ namespace jdi
float WeakPeakLabelColumnWidth(HFONT uiSmall)
{
return std::max(UiMeasureTextWidth(uiSmall, kWeakPeakLevelLabel),
UiMeasureTextWidth(uiSmall, kWeakPeakIterationsLabel));
UiMeasureTextWidth(uiSmall, kWeakPeakIterationsSliderLabel));
}
/**
@@ -1391,7 +1511,6 @@ namespace jdi
// 数据层结构变化后边沿统计与均值滤波曲线的派生结果都会失效。
m_edgeScans.clear();
m_edgeScanBudget = 0;
m_edgeScanTick = 0;
m_meanCurveCache.clear();
m_weakPeakCurveCache.clear();
// 信号下标可能已经整体位移:清掉按引用缓存的悬停/拖拽目标,避免指向错误的信号。
@@ -3238,19 +3357,28 @@ namespace jdi
return std::isfinite(drawnCenter) ? (drawn - drawnCenter) : nan;
};
// ---- 推进(受每 tick 预算限制)------------------------------------------
// ---- 推进(受「每帧总量 + 每状态配额」双重限制)--------------------------
// ⚠️ 预算在 `Paint` 开头**按帧**重置(不再按 `GetTickCount64()` 分桶):系统时钟
// 粒度约 15.6ms,帧率一高就会在同一 tick 内连画多帧而完全不推进 —— 用户报告的
// “按住横向光标不动时统计卡在统计中”“回车确认参数后不移动鼠标就一直停在部分值”
// 就是这个原因。
// ⚠️ 同时**每个状态各自持有配额**:否则推进总从列表第一个状态扫起,多条信号时
// 只有第一条有进展、其余长期显示“统计中…”(用户报告“很多项都停在统计中不更新”)。
const bool suppressPartial = state->justReset && !state->complete;
if (!state->complete && !state->blockRising.empty())
{
// 扫描预算按 tick 共享:同一帧里本函数会被调用多次(面板宽度测量 + 绘制 + 多条信号),
// 若每次都各扫一份,单帧总开销就会翻好几倍(实测整段 25.8M 采样 ≈115ms)。
const std::uint64_t tick = GetTickCount64();
if (tick != m_edgeScanTick)
const std::size_t activeStates = std::max<std::size_t>(1, m_edgeScans.size());
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);
if (state->pumpedFrame != m_edgeScanFrame)
{
m_edgeScanTick = tick;
m_edgeScanBudget = kEdgeScanPairsPerTick;
state->pumpedFrame = m_edgeScanFrame;
state->pumpedPairs = 0;
}
if (m_edgeScanBudget > 0)
std::size_t quota = (perState > state->pumpedPairs) ? (perState - state->pumpedPairs) : 0;
quota = std::min(quota, m_edgeScanBudget);
if (quota > 0)
{
state->justReset = false;
// 快路径:时间单调、无水平镜像、无「调整」变换、非中心轴 ⇒ 直接比较采样值与阈值。
@@ -3260,12 +3388,13 @@ namespace jdi
std::size_t scanned = 0;
std::size_t pair = state->scannedPair;
std::size_t block = (pair > 0) ? ((pair - 1) / kEdgeBlockSamples) : 0;
while (pair < count && scanned < m_edgeScanBudget)
while (pair < count && scanned < quota && m_edgeScanBudget > 0)
{
const std::size_t blockFirst = block * kEdgeBlockSamples + 1;
const std::size_t blockLast = std::min(count - 1, (block + 1) * kEdgeBlockSamples);
const std::size_t from = std::max(pair, blockFirst);
const std::size_t to = std::min(blockLast, from + (m_edgeScanBudget - scanned) - 1);
const std::size_t avail = std::min(quota - scanned, m_edgeScanBudget);
const std::size_t to = std::min(blockLast, from + avail - 1);
if (to < from)
{
break;
@@ -3340,6 +3469,7 @@ namespace jdi
state->localEnd = 0;
state->localValid = false;
}
state->pumpedPairs += scanned;
m_edgeScanBudget -= std::min(m_edgeScanBudget, scanned);
}
}
@@ -3454,7 +3584,12 @@ namespace jdi
kEdgePanelTitleHeight * m_scale;
// 数据行 = 名称与“上升沿 N / 下降沿 M”两行,取较宽者。
// 用 UiMeasureTextWidth(无省略号)测量,保证数字完整显示。
// ⚠️ 名称宽度有上限(`kEdgePanelNameMaxWidth`):超长信号名不再把面板顶得很长,
// 绘制时超出部分用省略号(用户明确要求“超过某个长度就省略名称显示”)。
// ⚠️ 统计信息按「上升沿 / 下降沿」**两列**排版(列起点固定)→ 多条信号时文字垂直对齐。
float nameColumnWidth = 0.0f;
float statRisingWidth = 0.0f;
float statFallingWidth = 0.0f;
for (const SignalRef& ref : lane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
@@ -3462,7 +3597,10 @@ namespace jdi
{
continue;
}
const float nameWidth = UiMeasureTextWidth(m_fonts->uiSmall, resolved.data->Title());
const float nameWidth =
std::min(UiMeasureTextWidth(m_fonts->uiSmall, resolved.data->Title()),
kEdgePanelNameMaxWidth * m_scale);
nameColumnWidth = std::max(nameColumnWidth, nameWidth);
if (itemized)
{
// 逐条目面板:第一排 = 名称 + 复选框;第三排(中心轴)= 统计结果。
@@ -3492,11 +3630,25 @@ namespace jdi
EdgeCountsFor(*resolved.file, *resolved.data, *resolved.style, lane.edgeValue,
EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling, complete);
}
// 宽度按实际要显示的文本测量(含未算完时的“统计中…”/省略号),
// 否则推进过程中数字变长会被截断(统计数字必须完整显示)。
const float countWidth =
UiMeasureTextWidth(m_fonts->monoSmall, EdgeCountsText(rising, falling, complete));
width = std::max(width, kEdgePanelPad * 2.0f * m_scale + std::max(nameWidth, countWidth));
// 宽度按实际要显示的文本测量(含未算完时的“…”),否则推进过程中数字变长会被截断。
// 完全未算出时整行显示“统计中…”,因此两列都要预留它的宽度。
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)
{
// 两列**等宽**(取两列最大值中的较大者)→ 绘制时两列起点固定,
// 多条信号的“上升沿 / 下降沿”文字严格垂直对齐(用户明确要求)。
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));
}
if (centerAxis || meanFilter)
@@ -3515,10 +3667,11 @@ namespace jdi
if (weakPeak)
{
// 滑块行 = 「标签列 + 轨道(最小宽度)+ 数值列」(等级与迭代次数两行共用同一列宽)。
// 滑块行 = 「标签列 + 轨道(最小宽度)+ 数值列 + 复位按钮」(等级与迭代次数两行共用同一列宽)。
const float rowWidth = WeakPeakLabelColumnWidth(m_fonts->uiSmall) + 6.0f * m_scale +
kWeakPeakTrackMinWidth * m_scale + 6.0f * m_scale +
WeakPeakValueColumnWidth(m_fonts->monoSmall);
WeakPeakValueColumnWidth(m_fonts->monoSmall) +
kWeakPeakResetGap * m_scale + kWeakPeakResetButtonSize * m_scale;
width = std::max(width, kEdgePanelPad * 2.0f * m_scale + rowWidth);
}
@@ -4616,6 +4769,21 @@ namespace jdi
{
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)))
@@ -5029,10 +5197,43 @@ namespace jdi
// 按**层序**取最上层的面板(与点击命中保持一致,重叠时以最上层为准)。
bool overEdgePanel = false;
int panelLane = -1;
int hoverPanelKind = 0;
{
int hoverKind = 0;
overEdgePanel = TopPanelAtPoint(static_cast<float>(x), static_cast<float>(y), panelLane,
hoverKind);
hoverPanelKind);
}
// 「弱峰调整」每行滑块右侧的**复位控件**:悬停时给出一次性提示(进入时设置、离开时清除)。
// 与「系数/偏移」二级面板标题栏的还原图标同一交互语义(悬停高亮 + 手型 + 提示)。
bool overPeakReset = false;
if (overEdgePanel && panelLane >= 0 && m_tab != nullptr &&
static_cast<EdgePanelKind>(hoverPanelKind) == EdgePanelKind::PEAK)
{
const EdgePanelLayout resetLayout = EdgePanelLayoutFor(panelLane, EdgePanelKind::PEAK);
for (std::size_t rowIndex = 0; rowIndex < resetLayout.rowLevelResets.size(); ++rowIndex)
{
const gd::RectF& level = resetLayout.rowLevelResets[rowIndex];
if (level.Width > 0.0f && UiPointInRect(level, fx, static_cast<float>(y)))
{
overPeakReset = true;
break;
}
if (rowIndex < resetLayout.rowIterResets.size())
{
const gd::RectF& iter = resetLayout.rowIterResets[rowIndex];
if (iter.Width > 0.0f && UiPointInRect(iter, fx, static_cast<float>(y)))
{
overPeakReset = true;
break;
}
}
}
}
if (overPeakReset != m_peakResetHover)
{
m_peakResetHover = overPeakReset;
SetHint(overPeakReset ? std::wstring(L"点击恢复该参数的默认值") : std::wstring());
changed = true;
}
// 悬停网格:鼠标在面板上时高亮面板所属网格(用户明确要求)。
@@ -5320,6 +5521,28 @@ namespace jdi
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 &&
@@ -6382,6 +6605,13 @@ namespace jdi
BuildLaneVisuals();
// 「边沿计数」的扫描预算**按帧重置**(见 `m_edgeScanFrame` 的说明):
// 旧实现按 `GetTickCount64()` 分桶,帧率一高(`PostMessage` 自持重绘会把它拉高)
// 就会在同一系统 tick 内连画多帧而**完全不推进**统计 —— 用户报告
// “按住横向光标不动时统计卡住不更新”“回车确认参数后不移动鼠标就停在部分值”。
++m_edgeScanFrame;
m_edgeScanBudget = kEdgeScanPairsPerFrame;
// 双缓冲:清掉上一帧钉住的曲线条目(本帧会按需重新钉住),并清理已被更新条目覆盖 /
// 超出内存预算的旧条目。此刻没有任何条目被钉住,回收是安全的。
m_curvePins.clear();
@@ -10799,11 +11029,15 @@ namespace jdi
// 滑块行列宽(与 `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 - 6.0f * m_scale) -
weakPeakTrackLeft);
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)
{
@@ -10843,13 +11077,24 @@ namespace jdi
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());
@@ -11585,6 +11830,15 @@ namespace jdi
{
mixRect(rect);
}
// 弱峰调整每行滑块右侧的复位按钮(悬停高亮依赖它的精确矩形)。
for (const gd::RectF& rect : layout.rowLevelResets)
{
mixRect(rect);
}
for (const gd::RectF& rect : layout.rowIterResets)
{
mixRect(rect);
}
// 鼠标落在面板内时,✕ / 复选框 / 滑块 / 底部按钮的悬停态都依赖精确坐标。
const float margin = 6.0f * m_scale;
@@ -11709,6 +11963,27 @@ namespace jdi
// 面板是**静态**内容:只有被拖动 / 悬停 / 参数变化的那一个真正重画,
// 其余直接贴上一帧缓存好的像素(15 个面板全画一遍实测约 33ms,
// 而贴图 <1ms —— 拖动弱峰调整滑块时的主要卡顿就来自这一层)。
// 「上升沿 / 下降沿」两列排版(列起点固定 ⇒ 多条信号的文字严格垂直对齐)。
// ⚠️ 未完成时**两个数字后面都带 `…`**(旧实现只在整行末尾追加一次,
// 视觉上只跟在“下降沿”的数字后面;用户明确要求为“上升沿”也补上这个尾缀)。
const auto paintCounts = [this, &g](const gd::RectF& rect, std::size_t rising,
std::size_t falling, bool complete)
{
if (EdgeCountsPending(rising, falling, complete))
{
UiText(g, L"统计中…", m_fonts->monoSmall, rect, m_theme->textMuted, TextAlign::LEFT);
return;
}
const float gap = kEdgePanelStatColumnGap * m_scale;
const float columnWidth = std::max(10.0f, (rect.Width - gap) * 0.5f);
UiText(g, EdgeRisingText(rising, complete), m_fonts->monoSmall,
gd::RectF(rect.X, rect.Y, columnWidth, rect.Height), m_theme->textMuted,
TextAlign::LEFT);
UiText(g, EdgeFallingText(falling, complete), m_fonts->monoSmall,
gd::RectF(rect.X + columnWidth + gap, rect.Y, columnWidth, rect.Height),
m_theme->textMuted, TextAlign::LEFT);
};
std::vector<PanelSlot> nextSlots;
std::vector<gd::RectF> dirtyRects;
const std::uint64_t panelGlobalKey = PanelGlobalKey();
@@ -11873,9 +12148,10 @@ namespace jdi
: (weakPeak ? tabLane.WeakPeakEnabled(ref)
: tabLane.EdgeCenterEnabled(ref));
// 名称:第一排左侧,避开右侧复选框。
// 名称:第一排左侧,避开右侧复选框(超长时省略)。
const float nameWidth =
std::max(20.0f, checkRect.X - (panel.X + pad) - 6.0f * m_scale);
std::min(std::max(20.0f, checkRect.X - (panel.X + pad) - 6.0f * m_scale),
kEdgePanelNameMaxWidth * m_scale);
UiText(g, UiEllipsize(resolved.data->Title(), m_fonts->uiSmall, nameWidth),
m_fonts->uiSmall,
gd::RectF(panel.X + pad, checkRect.Y - 3.0f * m_scale, nameWidth,
@@ -11930,11 +12206,34 @@ namespace jdi
track.Height + 16.0f * m_scale),
m_theme->textStrong, TextAlign::LEFT);
};
// 每行滑块右侧的**复位控件**(与「系数/偏移」二级面板里的还原图标同一套绘制):
// 悬停时高亮 + 手型光标,点击把该行参数恢复为默认值(幅度 0 / 迭代 1 且上限回初始值)。
const auto paintSliderReset = [&](const gd::RectF& rect)
{
if (rect.Width <= 0.0f)
{
return;
}
const bool hot =
(m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) &&
UiPointInRect(rect, static_cast<float>(m_mouseX),
static_cast<float>(m_mouseY));
if (hot)
{
UiFillRoundRect(
g, gd::RectF(rect.X - 1.0f * m_scale, rect.Y - 1.0f * m_scale,
rect.Width + 2.0f * m_scale,
rect.Height + 2.0f * m_scale),
3.0f * m_scale, WithAlpha(m_theme->accent, 0x33));
}
PaintPanelResetIcon(g, rect,
hot ? m_theme->accent : m_theme->textMuted, m_scale);
};
const gd::RectF& levelTrack = layout.rowLevelTracks[rowIndex];
const gd::RectF& iterTrack = layout.rowIterTracks[rowIndex];
paintSliderLabel(levelTrack, kWeakPeakLevelLabel);
paintSliderLabel(iterTrack, kWeakPeakIterationsLabel);
paintSliderLabel(iterTrack, kWeakPeakIterationsSliderLabel);
const bool levelActive = (m_dragMode == PlotDragMode::PEAK_LEVEL) &&
(m_peakDragLane == static_cast<int>(index)) &&
@@ -11950,6 +12249,7 @@ namespace jdi
(peakItem->amountPercent > 0 ? L"+" : L"") +
std::to_wstring(peakItem->amountPercent) + L"%",
levelActive);
paintSliderReset(layout.rowLevelResets[rowIndex]);
// 迭代次数滑块:量程 = 1 … 该信号自己的上限。
const int peakMax = std::max(kWeakPeakIterationsMin,
@@ -11972,6 +12272,7 @@ namespace jdi
{
paintSliderBody(iterTrack, iterRatio, std::to_wstring(peakIter),
iterActive);
paintSliderReset(layout.rowIterResets[rowIndex]);
}
}
}
@@ -12042,10 +12343,8 @@ namespace jdi
statsPoints, statsOrder, EdgeRangeActive(),
EdgeRangeMin(), EdgeRangeMax(), rising, falling,
statsComplete);
const std::wstring counts =
EdgeCountsText(rising, falling, statsComplete);
UiText(g, counts, m_fonts->monoSmall, statsRect, m_theme->textMuted,
TextAlign::LEFT);
// 两列排版(列起点固定)⇒ 多条信号的“上升沿 / 下降沿”垂直对齐。
paintCounts(statsRect, rising, falling, statsComplete);
}
}
}
@@ -12065,10 +12364,12 @@ namespace jdi
}
const std::uint32_t signalColor = StyleColor(*resolved.style);
const float textWidth = panel.Width - pad * 2.0f;
// 名称与统计数字一律完整绘制(不省略):面板宽度已按文本实测宽度确定。
UiText(g, resolved.data->Title(), m_fonts->uiSmall,
gd::RectF(panel.X + pad, rowY, textWidth, rowHeight), signalColor,
TextAlign::LEFT);
// 名称:超长时省略(面板宽度被 `kEdgePanelNameMaxWidth` 限制,不会把面板顶得很长)。
UiText(g,
UiEllipsize(resolved.data->Title(), m_fonts->uiSmall,
std::min(textWidth, kEdgePanelNameMaxWidth * m_scale)),
m_fonts->uiSmall, gd::RectF(panel.X + pad, rowY, textWidth, rowHeight),
signalColor, TextAlign::LEFT);
std::size_t rising = 0;
std::size_t falling = 0;
@@ -12076,10 +12377,8 @@ namespace jdi
EdgeCountsFor(*resolved.file, *resolved.data, *resolved.style, tabLane.edgeValue,
EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling,
complete);
const std::wstring counts = EdgeCountsText(rising, falling, complete);
UiText(g, counts, m_fonts->monoSmall,
gd::RectF(panel.X + pad, rowY + rowHeight, textWidth, rowHeight),
m_theme->textMuted, TextAlign::LEFT);
paintCounts(gd::RectF(panel.X + pad, rowY + rowHeight, textWidth, rowHeight), rising,
falling, complete);
rowY += rowHeight * 2.0f;
}
@@ -12155,14 +12454,13 @@ namespace jdi
// 编辑态:强调色外框 + 选区高亮 + 插入符,首次键入即整体替换(与常规输入框一致)。
UiDrawEditField(g, fieldRect, m_edit, m_fonts->monoSmall, m_scale);
// 「迭代次数 N 上限」:输入框**以空内容开始**,未输入任何内容时以灰色提示当前上限值
//(用户明确要求:"在未输入任何内容时,输入框内提示当前上限值")。
// 提示文本比插入符稍微右移几像素,避免插入符压在上面看不清数字。
// 「迭代次数 N 上限」:输入框**以空内容开始**,未输入任何内容时以灰色提示当前上限值。
// 提示文案 = “输入上限(当前N)”(用户明确要求:旧实现只显示一个孤立的数字,缺少说明)。
if (m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX && m_edit.Text().empty())
{
const int currentMax = m_peakEditCurrentMax > 0 ? m_peakEditCurrentMax
: kWeakPeakIterationsDefaultMax;
const std::wstring hint = std::to_wstring(currentMax);
const std::wstring hint = L"输入上限(当前" + std::to_wstring(currentMax) + L")";
const float hintLeft = fieldRect.X + 11.0f * m_scale;
UiTextAtClipped(g, hint, m_fonts->monoSmall, hintLeft,
gd::RectF(hintLeft, fieldRect.Y,
+61 -15
View File
@@ -193,24 +193,65 @@ namespace jdi
}
/**
* @brief 带正负符号的数值文本:正数也带 `+`(非编辑态显示偏移量时使用)。
* @brief 带正负符号的数值文本(非编辑态显示偏移量时使用)。
*
* 规则(用户明确要求):**正数带 `+`、负数带 `-`、0 不带任何符号**。
* (旧实现 0 也会显示 `+0.00`,用户报告“偏移调整的 Y 轴输入框默认值为 0 时也显示了正号,这不正确”。)
* @param [IN] value 数值。
* @return 形如 `+1.50` / `-0.25` / `+0.00` 的文本。
* @return 形如 `+1.50` / `-0.25` / `0.00` 的文本。
*/
std::wstring FormatSignedNumber(double value)
{
const std::wstring text = FormatNumberText(std::fabs(value));
return ((value < 0.0) ? L"-" : L"+") + text;
if (value > 0.0)
{
return L"+" + text;
}
if (value < 0.0)
{
return L"-" + text;
}
return text; // 0:不带符号
}
/**
* @brief 把系数格式化为界面文本(与偏移量同一套两位小数规则)。
* @brief 把系数格式化为界面文本(与偏移量同一套两位小数规则 + 显式正号)。
*
* 规则(用户明确要求):“系数调整”输入框**非编辑态**显示时,非 0 正值前面加 `+`,
* 负值按原样显示负号,0 不带符号。编辑态仍用不带符号的纯数值(见 `FormatNumberText`)。
* @param [IN] gain 系数。
* @return 显示文本。
*/
std::wstring FormatGainText(float gain)
{
return FormatNumberText(static_cast<double>(gain));
const double value = static_cast<double>(gain);
if (value > 0.0)
{
return L"+" + FormatNumberText(value);
}
return FormatNumberText(value);
}
/**
* @brief 「时间轴」偏移的非编辑态显示文本(`Δt:` + 带正负号的复合单位间隔)。
*
* 规则(用户明确要求):在 `Δt:` 冒号后的时间显示值**前面加上正负号**,
* 只有值为 0 时不显示符号。示例:`Δt:+1h 1min 2s` / `Δt:-5s 60ms` / `Δt:0s`。
*(`FormatDurationCompound` 内部取绝对值,因此符号由这里补上。)
* @param [IN] seconds 时间偏移(秒,可为负)。
* @return 显示文本。
*/
std::wstring FormatOffsetTimeText(double seconds)
{
if (seconds > 0.0)
{
return L"Δt:+" + FormatDurationCompound(seconds);
}
if (seconds < 0.0)
{
return L"Δt:-" + FormatDurationCompound(seconds);
}
return L"Δt:" + FormatDurationCompound(0.0);
}
}
@@ -2012,13 +2053,15 @@ namespace jdi
gainHeader.id = CMD_GAIN_RESET; // 点击标题栏右侧的还原图标 → 系数重置为 1
items.push_back(gainHeader);
// 非编辑态带正号(用户明确要求);进入编辑后的初始文本用不带符号的纯数值。
const std::wstring gainText = FormatGainText(style->gain);
const std::wstring gainEditText = FormatNumberText(static_cast<double>(style->gain));
MenuItem gainEdit;
gainEdit.kind = MenuItemKind::EDIT;
gainEdit.label.clear(); // 不显示说明文字:输入框独占整行
gainEdit.id = CMD_GAIN_EDIT;
gainEdit.value = gainText;
gainEdit.editValue = gainText;
gainEdit.editValue = gainEditText;
gainEdit.filter = EditFilter::NUMBER;
items.push_back(gainEdit);
@@ -2039,13 +2082,13 @@ namespace jdi
items.push_back(offsetY);
// 「时间轴」:把采样沿时间轴平移(画在 t + offsetTime)。
// 非编辑态显示与双光标差值同一套复合单位文本;编辑态显示以秒为单位的数值,
// 并在输入框最右侧提示单位 `s`。
// 非编辑态显示与双光标差值同一套复合单位文本,并**带正负号**(用户明确要求);
// 编辑态显示以秒为单位的数值,并在输入框最右侧提示单位 `s`。
MenuItem offsetTime;
offsetTime.kind = MenuItemKind::EDIT;
offsetTime.label = L"时间轴";
offsetTime.id = CMD_OFFSET_TIME;
offsetTime.value = L"Δt:" + FormatDurationCompound(style->offsetTime);
offsetTime.value = FormatOffsetTimeText(style->offsetTime);
offsetTime.editValue = FormatNumberText(style->offsetTime);
offsetTime.editSuffix = L"s";
offsetTime.filter = EditFilter::NUMBER;
@@ -2907,7 +2950,8 @@ namespace jdi
}
if (m_menu != nullptr)
{
m_menu->UpdateItemValue(CMD_GAIN_EDIT, FormatGainText(style->gain), FormatGainText(style->gain));
m_menu->UpdateItemValue(CMD_GAIN_EDIT, FormatGainText(style->gain),
FormatNumberText(static_cast<double>(style->gain)));
}
return false;
}
@@ -2936,7 +2980,7 @@ namespace jdi
if (m_menu != nullptr)
{
m_menu->UpdateItemValue(CMD_OFFSET_TIME,
L"Δt:" + FormatDurationCompound(style->offsetTime),
FormatOffsetTimeText(style->offsetTime),
FormatNumberText(style->offsetTime));
}
return false;
@@ -2959,7 +3003,8 @@ namespace jdi
style->gain = 1.0f;
if (m_menu != nullptr)
{
m_menu->UpdateItemValue(CMD_GAIN_EDIT, FormatGainText(style->gain), FormatGainText(style->gain));
m_menu->UpdateItemValue(CMD_GAIN_EDIT, FormatGainText(style->gain),
FormatNumberText(static_cast<double>(style->gain)));
}
}
return false;
@@ -2974,9 +3019,10 @@ namespace jdi
style->offsetTime = 0.0;
if (m_menu != nullptr)
{
m_menu->UpdateItemValue(CMD_OFFSET_Y, FormatSignedNumber(0.0), L"0.00");
m_menu->UpdateItemValue(CMD_OFFSET_TIME, L"Δt:" + FormatDurationCompound(0.0),
L"0.00");
m_menu->UpdateItemValue(CMD_OFFSET_Y, FormatSignedNumber(0.0),
FormatNumberText(0.0));
m_menu->UpdateItemValue(CMD_OFFSET_TIME, FormatOffsetTimeText(0.0),
FormatNumberText(0.0));
}
}
return false;
+112
View File
@@ -1275,6 +1275,88 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸(
---
### 9.26 「调整」数值符号规则 + 边沿统计按帧推进(含首帧公平分摊)+ 浮动面板排版与弱峰复位控件(本轮)
> 背景(用户报告):① 时间轴偏移 / 系数 / Y 偏移的符号显示不符合要求;
> ② **按住**横向光标不动时边沿计数停在“统计中…”,松开鼠标才更新;
> ③ 改中心轴参数回车后**不移动鼠标**时统计停在部分值(数字后还有 `…`);
> ④ 浮动面板没有信号名长度上限,超长名字把面板顶得很长;
> ⑤ 多信号统计信息错位(没有排版),且只有“下降沿”的数字带 `…`;
> ⑥ 弱峰调整面板需要每行复位控件、上限输入框提示文案、并去掉滑块前多余的 `%` / `N` 标签。
#### 一、「调整」类数值的符号规则(`SignalPanel`)
- `FormatSignedNumber`:**0 不再显示 `+`**(旧实现恒带符号 → `+0.00`);
Y 轴偏移默认值现在显示 `0.00`。
- `FormatGainText`:非 0 正值带 `+`、负值带 `-`、0 不带符号。
⚠️ 仅用于**非编辑态**显示;进入编辑的初始文本仍用不带符号的纯数值(`FormatNumberText`),
避免用户一进编辑框就看到 `+`。
- 新增 `FormatOffsetTimeText(double)`:`Δt:` 后带正负号 ——
`Δt:+5s 60ms` / `Δt:-5s 60ms` / `Δt:0s`(`FormatDurationCompound` 内部取绝对值,
符号由这里补)。打开面板、`CMD_OFFSET_TIME` 提交、`CMD_OFFSET_RESET` 三处统一走它。
#### 二、边沿统计推进:**按帧**预算 + 每状态**公平配额**
- **根因(用户报告②③)**:预算按 `GetTickCount64()` 分桶,而系统时钟粒度约 15.6ms;
`RequestEdgeScanFrame` 的 `PostMessage` 自持重绘会把帧率拉到远高于它 ⇒
**同一 tick 内连画多帧但完全不推进**。“按住不动”“回车后不动鼠标”因此停住。
修法:`Paint` 开头 `++m_edgeScanFrame; m_edgeScanBudget = kEdgeScanPairsPerFrame;`
(**按帧**重置),删除 `m_edgeScanTick`。
- **每状态配额**(修“很多项停在统计中不更新”):`perState = max(kEdgeScanPairsPerStateMin,
kEdgeScanPairsPerFrame / share)`,`share = max(kEdgeScanFairShare = 6, m_edgeScans.size())`;
每个状态记录 `pumpedFrame` / `pumpedPairs`,新帧开始时清零。
⚠️ **不能直接用 `size()` 分摊**:状态是**同一帧内逐个创建**的,第一个状态那一刻 `size() == 1`
会拿走整额预算,同帧后面新建的状态全部被截断为 0 配额(表现为“首帧只有第一条信号推进”)。
- 实测(合成 5 信号 × 200 万采样、全部同一网格):**首帧 5 个状态全部推进**;
**9 帧内全部扫完**(期间不注入任何鼠标消息);统计值与参考实现**逐位一致**;
阈值变化(拖动横向光标)后就地重置,随后“停住不动”仍能扫完且精确。
- 自持驱动断言:未完成时确实投递 `kMessagePlotDerivedReady` —— 这是
“用户不移动鼠标也会持续推进”的**唯一**驱动源,任何“分帧推进”的新逻辑都要照做。
#### 三、浮动面板排版
- **信号名宽度上限** `kEdgePanelNameMaxWidth = 150`(逻辑像素):`EdgePanelWidth` 与绘制
都用 `min(实测宽度, 上限)` 并走 `UiEllipsize`(超长名字不再把面板顶长)。
实测:300 字符名字的面板宽度只比单字符名字宽 **44px**(上限 225px @scale1.5),
不设上限则要多 **5100px**。
- **统计信息两列等宽**(修“错位”):新增 `EdgeRisingText` / `EdgeFallingText` /
`EdgeCountsPending`;面板宽度按 **`2 × max(两列文本宽) + kEdgePanelStatColumnGap`**
预留,绘制时两列各占 `(可用宽 − 间距) / 2` ⇒ 多条信号的“上升沿 / 下降沿”
**列起点固定、严格垂直对齐**(用户明确要求)。
⚠️ 绘制函数 `paintCounts` 与宽度计算必须用**同一套等宽假设**,否则较窄的那一列会被截断。
- **两个数字后面都带 `…`**:旧实现只在整行末尾追加一次,视觉上只跟在“下降沿”的数字后;
现在 rising / falling 各自在未完成时追加 ` …`。
#### 四、弱峰调整面板
- 每行滑块右侧新增**复位控件**(复用「系数/偏移」二级面板的还原图标绘制:
PlotView 内新增 `PaintPanelResetIcon`,与 `PopupMenu::PaintResetIcon` 同一套笔画):
悬停高亮 + `IDC_HAND` + 一次性提示“点击恢复该参数的默认值”;
点击把该行参数恢复默认(幅度滑块 → `amountPercent = 0`;迭代滑块 → `iterations = 1`
**且 `maxIterations` 回到初始上限 10**)。
`EdgePanelLayout` 新增 `rowLevelResets` / `rowIterResets`(并进 `PanelSlotKey` 以保证
悬停高亮能触发重绘),两行按钮共用同一 X(上下对齐),面板宽度计入按钮列。
- 「迭代次数上限」输入框的空内容提示改为 **“输入上限(当前N)”**(旧实现只显示一个数字)。
- 滑块标签去掉多余的 `%` / `N`:`kWeakPeakLevelLabel = L"调整幅度"`、
新增 `kWeakPeakIterationsSliderLabel = L"迭代次数"`;
⚠️ MEAN / CENTER 参数输入框的标签常量 `kWeakPeakIterationsLabel`(`迭代次数 N`)**保持不变**。
#### 五、验证(全部离屏,进程/线程均 `BELOW_NORMAL`,窗口 `SW_HIDE`,不注入真实鼠标/键盘)
- 新增探针 `.preflight/perf/panelcheck_main.cpp`(合成 5 信号 × 200 万采样、同一网格,
只写本地 stdout 的 ASCII):**46/46 通过** —— 覆盖
符号规则 10 条(全部走**真实菜单路径**:`OpenGainMenu` + 读 `PopupMenu` 条目)、
按帧推进 / 不饿死 / 精确性(与独立参考实现逐位比对)/ 自持驱动、
两列等宽预留(含未完成时两处 `…`)、名字上限、弱峰复位控件(存在 / 位置 / 对齐 /
点击恢复默认值与上限)。
- 既有 `verify_main.cpp`(本机版本)**47/47(另有 2 条已知过期断言)**:
`derived offsetTime kept (cannot be baked)`(§9.21 已推翻该规则)与
`mouse in lane1 lower half -> target 2`(§9.21 已把让位判据改为“严格超过 ±0.5H”)。
本机 `.preflight` 是**本机自建**、未随远端同步 ⇒ 这两条是**旧规则断言**,与本轮改动无关。
- `make release` 零警告。
---
## 10. 变更日志(本文件维护记录)
| 日期 | 设备/模型 | 摘要 |
@@ -1313,6 +1395,7 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸(
| 2026-10-14 | DeepSeek-V4.1-Flash(**起源设备**) | **修复“偶发的改一个参数、其它信号也重新统计”(详见 §9.25)**:根因是中心轴统计靠 `AcquireCoveringCurve`/`ScheduleCurveJob` 拿**整条**曲线 — 千万级信号一条 ≈103MB,多试几个参数值就把曲线缓存推到 768MB 上限 → `PruneCurveEntriesLocked` 从队首淘汰(往往是**其它信号**的曲线)→ 那些信号显示“统计中…”并在后台重新排队(**偶发**,取决于缓存里积了多少条目)。修法:`EdgeScanState` 新增 `localCurve/localBegin/localEnd/localValid`,扫描时**就地计算「当前扫描块 ± 滤波半径」**(半径 = `points*iterations`,窗口 ≥ 8 个块,成本计入扫描预算,扫完即释放)⇒ 与曲线缓存、与其它信号**彻底解耦**(实测统计时 `curve entries 0`);删除“曲线未就绪就把整 tick 预算清零”的门控(它会把同帧后面排队的其它统计**饿死**);`kEdgeScanStateLimit` **16→64** 且超限时优先淘汰「已扫完」的状态。验证:新增间歇性探针 `[AU]`(5 信号同网格、真实编辑路径连改 25 次、逐帧分类 CASE A/B/C)——修复前 baseline **0/10 完成**,修复后 **10/10 完成且 25 次编辑 CASE A/B/C 全 0**;新增「本地窗口 vs 整条曲线」12 组参数(points 5/17/64/300 × iterations 1/2/4)**逐位一致**断言;合计 **789/789 通过**,`make release` 零警告。 |
| 2026-10-14 | DeepSeek-V4.1-Flash(**起源设备**) | **新标签页分组默认折起 + GX 徽标悬停不改光标 + 标题栏 RAM 改用「私有工作集」+ 边沿计数改为「逐块累积 + 每 tick 预算推进」(详见 §9.22)**。① `NewTab(bool collapseGroups)`(首个标签页 false、用户新建 true)+ `TabConfig::SetGroupsCollapsedByDefault/SetAllCollapsed`;`SyncToDataSet` 用该默认值初始化**新出现的**分组。② `QueryCursor` 的 GX 徽标分支由 `IDC_HAND` 改为 `nullptr`(悬停不改形状,仅拖拽时 `IDC_SIZENS`)。③ **RAM 口径修正**:原取 `WorkingSetSize`(总工作集,含映射 DLL/共享页与已释放未回收的驻留页)→ 改为 `PROCESS_MEMORY_COUNTERS_EX2::PrivateWorkingSetSize`(与任务管理器「内存」列一致;失败退回 `PrivateUsage` → `WorkingSetSize`)。实测加载后 117.55/109.96 MiB、关闭分组后 15.44/7.62 MiB —— **无泄漏,内存完整归还**。④ **边沿计数性能**:改前 25.8M 采样 ≈**115ms/条/帧**(5 条同网格 = 575ms/帧,用户报告"开启即卡顿、之后拖不动");改为按 `kEdgeBlockSamples=8192` 分块累积、每 tick 预算 `kEdgeScanPairsPerTick=1.2M`、区间用块和相加(区间不进键)、拖动横向光标期间不推进并显示上一个完整值 + `complete=false`(`EdgeCountsText` 显示"统计中…/…");实测首帧 1.7ms、单次最坏 1.9ms、拖动中 10 次查询 0.11ms、最终结果与参考实现逐位一致。⑤ **内存排查报告**(用户要求,第 4 项只排查未改动):弱峰调整工作缓冲实测 **≈34 B/采样**(极值表 `PeakExtremum` 24 B/极值为主导),**4 条工作线程并发 +412 MiB / 3M 采样** ⇒ 25.8M 采样峰值 ≈**3.5GB**(+数据 1.03GB +曲线缓存 ≤768MB ⇒ 4~5GB,与用户 4.4GB 观测吻合);**全仓无 `try`/`catch`** ⇒ 低内存时 `bad_alloc` 逸出线程入口 ⇒ `std::terminate()` 崩溃(无优雅降级机制)。验证:离屏 `uitest` **738/738 通过**(新增 `[AR]` 边沿计数预算/精确性/拖动期 O(1)、`[AS]` 标题栏 RAM 口径),`make release` 零警告。 |
| 2026-10-14 | DeepSeek-V4.1-Flash(**起源设备**) | **边沿计数改用「绘制值」+ 派生信号烘入时间偏移 + 图例托底统一为 GX 徽标款 + 调整二级面板向上展开(详见 §9.21)**。① **边沿计数恒为 0**:`EdgeCountsFor`/`EdgeCenterCountsFor` 只比原始采样值,而阈值是屏幕上的值 → 「系数 50」的信号统计恒为 0;修法:判定值改为 `ApplyDrawTransform(style, values[SourceIndexFor(...)])`(中心轴两侧同套变换;水平镜像时曲线窗口也要先映射下标);缓存新增 **`styleKey`**(否则改完「调整」仍命中旧计数)。② **区间边界点被误计**:`EdgeIndexRange` 左端“多取一个采样点”的外扩**已删除**,收紧为“**两个端点都在区间内**才计入”(光标恰落在越过阈值的点 B 上时 B 不再被算作上升沿)。③ **`offsetTime` 也烘进派生采样值**(**推翻 §9.19 的“唯一例外”**):按 `time[i]-offsetTime` 取最近采样点重采样(单调时间轴用只前进的游标,O(n);越界置 NaN),新信号样式**全默认**;非单调时间轴退回到把 `offsetTime` 带在样式上。④ **图例托底 = GX 徽标同款**(同高 / 圆角 = 高度一半 / 同配色 / 水平内边距 = 胶囊半径),文本改用新增字体 **`uiTiny`(10 逻辑 px)**;`kLegendPlateRadius` 已删。⑤ **`PopupMenu::Open(..., expandUp=true)`**(锤点为**左下角**、向上展开,`m_buildExpandUp` 供 `RebuildLayout` 复用)+ 「系数/偏移」「镜像」面板锤在按钮左上角再上移 `kAdjustMenuGap`。验证:`.preflight/uitest` 新增 `[AR]`(五组断言全部与**独立参考实现**逐位比对,含 A/B:“按原始采样值算会得到 7/6 个边沿”);**并同步修正 harness 里两处旧规则断言**(`[AP]` 派生信号继承 → 默认样式 + 烘值;`[AN]` 图例带无托底 → 装饰矩形逐块且暴露列可见底层内容);合计 **722/722 通过**,`make release` 零警告。 |
| 2026-10-15 | DeepSeek-V4.1-Flash(**另一台设备**) | **「调整」数值符号规则 + 边沿统计按帧推进(含首帧公平分摊)+ 浮动面板排版与弱峰复位控件(详见 §9.26)**。① **符号**:Y 轴偏移 0 不再显示 `+0.00`;系数非 0 正值带 `+`(编辑态初始文本仍无符号);`Δt:` 后按正负带符号(`Δt:+5s 60ms` / `Δt:-5s 60ms` / `Δt:0s`)。② **边沿统计卡住**根因:预算按 `GetTickCount64()` 分桶(系统粒度 15.6ms)而 `PostMessage` 自持重绘把帧率拉高 ⇒ 同一 tick 内多帧完全不推进 → 改为 **`Paint` 开头按帧重置**;并给**每个状态独立每帧配额**(`share = max(6, m_edgeScans.size())`,避免同帧新建状态被第一个独占整额)→ 实测首帧 5 个状态全部推进、**9 帧内全部扫完且不注入任何鼠标消息**、与参考实现逐位一致。③ **面板排版**:信号名宽度上限 `kEdgePanelNameMaxWidth=150`(300 字符名字只多 44px,旧实现多 5100px);统计信息**两列等宽**(`2×max(列宽)+间距`)⇒ 多信号“上升沿/下降沿”列起点固定、垂直对齐;未完成时**两个数字后都带 `…`**(旧实现只跟在“下降沿”后)。④ **弱峰调整**:每行滑块右侧新增**复位控件**(复用还原图标,悬停高亮 + `IDC_HAND` + 提示;点击恢复幅度 0 / 迭代 1 且上限回 10);上限输入框提示改为“输入上限(当前N)”;滑块标签去掉多余的 `%` / `N`。验证:新增离屏探针 `panelcheck` **46/46 通过**(全部走真实菜单/点击路径),既有 `verify` 47/47(另 2 条为本机未同步的旧规则断言),`make release` 零警告。 |
---
@@ -1449,6 +1532,35 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸(
且超限时**优先淘汰「已扫完」的最旧状态**,绝不淘汰正在推进的。
上限太小会淘汰其它信号的已扫完状态 → 它们从头重扫(与第 39 条同一类症状)。
实测 10 个信号 × (普通+中心轴)= 20 个状态,原来的 16 就会踩到。
43. ⚠️ **“分帧推进”的预算必须按帧重置,禁止按 `GetTickCount64()` 分桶**:
系统时钟粒度约 15.6ms,而 `RequestEdgeScanFrame` 的 `PostMessage` 自持重绘会把帧率
拉到远高于它 ⇒ **同一 tick 内连画多帧但完全不推进**(用户报告“按住光标不动时统计
卡在统计中”“回车确认参数后不移动鼠标就停在部分值”)。
✓ 正确做法:`Paint` 开头 `++m_edgeScanFrame; m_edgeScanBudget = kEdgeScanPairsPerFrame;`。
44. ⚠️ **每个累积状态必须各自持有每帧配额**:共享一个总额 + “从列表第一个扫起”会让
多条信号里只有第一条有进展、其余长期显示“统计中…”(用户报告“很多项都停在统计中不更新”)。
⚠️ 分摊时**不能用 `m_edgeScans.size()` 当分母**:状态是**同一帧内逐个创建**的,
第一个状态那一刻 `size() == 1` 会独占整额,同帧后面新建的状态全部被截断为 0 配额
(表现为“首帧只有第一条信号推进”)。用 `share = max(kEdgeScanFairShare, size())`。
45. ⚠️ **「调整」类数值的符号规则**(用户明确要求,勿回退):
* **Y 轴偏移**:>0 带 `+`、<0 带 `-`、**0 不带符号**(旧实现恒带符号 → `+0.00`);
* **系数**:非 0 正值带 `+`、负值带 `-`、0 不带符号 —— 仅**非编辑态**;
进入编辑的初始文本用**不带符号**的纯数值;
* **时间轴偏移**:`Δt:` 后按正负带符号(`Δt:+5s 60ms` / `Δt:-5s 60ms` / `Δt:0s`),
统一走 `SignalPanel::FormatOffsetTimeText`(`FormatDurationCompound` 内部取绝对值)。
46. ⚠️ **浮动面板的信号名必须限宽**(`kEdgePanelNameMaxWidth = 150` 逻辑像素):宽度计算与
绘制都用 `min(实测, 上限)` + `UiEllipsize`;否则超长信号名会把面板顶得极长
(300 字符名实测多出 5100px,限宽后只多 44px)。
47. ⚠️ **边沿统计的“上升沿 / 下降沿”必须两列等宽排版**(列起点固定 → 多条信号垂直对齐):
面板宽度按 `2 × max(两列文本宽) + kEdgePanelStatColumnGap` 预留,绘制时两列各占
`(可用宽 − 间距) / 2`;⚠️ **绘制与宽度计算必须用同一套等宽假设**,否则较窄的列会被截断。
未完成时 **rising 与 falling 两个数字后面都要带 `…`**(旧实现只在整行末尾追加一次)。
48. ⚠️ **弱峰调整面板每行滑块右侧有复位控件**(与「系数/偏移」二级面板的还原图标同一套绘制):
点击把该行恢复默认 —— 幅度滑块 → `amountPercent = 0`;迭代滑块 → `iterations = 1`
**且 `maxIterations` 回到初始上限 10**。`EdgePanelLayout` 的 `rowLevelResets` /
`rowIterResets` **必须进 `PanelSlotKey`**(否则悬停高亮不会重绘);两行按钮共用同一 X。
⚠️ 滑块标签不得带多余的 `%` / `N`(但 MEAN / CENTER 参数输入框的 `迭代次数 N` 保持不变);
迭代次数上限输入框的空内容提示为 `输入上限(当前N)`。
### 11.3 离屏验证方法学(本机偏好,强烈建议沿用)