Compare commits
7
Commits
8271879ede
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30e9d65c30 | ||
|
|
0c320cf1b3 | ||
|
|
ea704b03ea | ||
|
|
8de8a8fb85 | ||
|
|
7a6fcd68e2 | ||
|
|
334015c1a9 | ||
|
|
944c074ab3 |
No files matched your search
@@ -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; /**< 与主题一致的浮动拾色器。 */
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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; /**< 竖带右边界(客户区像素)。 */
|
||||
|
||||
// ---- 每网格「信号图层」(前台同步维护;交互帧只做贴图 / 坐标偏移) ----
|
||||
//
|
||||
// 平移只改变绘制坐标、不改变画面内容。因此**每个网格各自维护一块离屏位图**
|
||||
|
||||
@@ -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 「边沿计数」结果的显示文本(合并成一行,用于宽度测量与兼容旧调用)。
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,18 +1071,67 @@ 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 &&
|
||||
for (std::size_t rowIndex = 0; rowIndex < layout.rowHeaders.size(); ++rowIndex)
|
||||
{
|
||||
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),
|
||||
@@ -1086,20 +1146,27 @@ namespace jdi
|
||||
{
|
||||
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 (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,
|
||||
// ⚠️ 下限:均值滤波 = 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,
|
||||
kMeanFilterPointsMin +
|
||||
static_cast<int>(std::lround(ratio * (maximum - kMeanFilterPointsMin)))));
|
||||
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,204 +2616,101 @@ 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))
|
||||
// 既不能自动勾选、也不能展开 / 收起,只能点复选框;此时鼠标指针也保持箭头)。
|
||||
// 仍然消费这次点击(不穿透到底层的平移 / 选区等)。
|
||||
for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex)
|
||||
{
|
||||
if (!insideContent)
|
||||
{
|
||||
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);
|
||||
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)];
|
||||
|
||||
// 「弱峰调整」:先判两行滑块(数值文本 → 改上限;轨道 → 拖动)。
|
||||
bool checked = false;
|
||||
bool collapsed = false;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 (meanMode)
|
||||
{
|
||||
ToggleLaneMeanFilterSignal(index, ref);
|
||||
}
|
||||
else if (boostMode)
|
||||
{
|
||||
ToggleLaneWeakPeakSignal(index, ref);
|
||||
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 if (centerMode)
|
||||
{
|
||||
ToggleLaneEdgeCenterSignal(index, ref);
|
||||
}
|
||||
// 勾选变化只改变该信号“是否显示”,它的统计状态(与参数相关)依然有效,
|
||||
// 其它信号更是完全无关 ⇒ **不要**清任何缓存(清掉就会让所有统计重算)。
|
||||
// 这里**不**整体作废场景缓存(交给 `m_sceneDerived` 快照驱动局部重画)。
|
||||
else
|
||||
{
|
||||
ToggleLaneMeanFilterSignal(index, ref);
|
||||
}
|
||||
m_meanRowHoverLane = -1;
|
||||
m_meanRowHoverRef = SignalRef();
|
||||
}
|
||||
else if (checked)
|
||||
{
|
||||
// 已启用:整行切换参数行的收起 / 展开。
|
||||
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;
|
||||
}
|
||||
// 第二排的两个输入框 → 就地编辑该信号的参数(仅 MEAN / CENTER 面板;
|
||||
// 弱峰调整用的是滑块,已在上面处理)。
|
||||
if (layout.rowPoints[rowIndex].Width > 0.0f &&
|
||||
UiPointInRect(layout.rowPoints[rowIndex], fx, fy))
|
||||
{
|
||||
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 && 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
@@ -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);
|
||||
}
|
||||
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,
|
||||
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;
|
||||
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);
|
||||
if (item != nullptr && !item->collapsed)
|
||||
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;
|
||||
// 「自由标记」的高度在下面的 `if (freeMarker)` 分支里计算(含底部固定区)。
|
||||
height = titleHeight;
|
||||
}
|
||||
else
|
||||
{
|
||||
itemsHeight += (kEdgePanelMeanEditHeight + kEdgePanelMeanRowGap) * m_scale;
|
||||
if (centerAxis)
|
||||
{
|
||||
itemsHeight += kEdgePanelMeanRowHeight * m_scale;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 底部按钮行:上方与条目之间、下方与面板下边界之间各留一个 `pad`,
|
||||
// 与面板的左右内边距完全一致(原先按钮直接贴着下边框,看起来很不协调)。
|
||||
height = titleHeight + pad + itemsHeight + pad + buttonHeight + (hasButton ? pad : 0.0f);
|
||||
}
|
||||
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;
|
||||
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;
|
||||
}
|
||||
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);
|
||||
const bool checked = (item != nullptr);
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 信号引用。
|
||||
|
||||
@@ -100,6 +100,12 @@ namespace jdi
|
||||
boxes.Add(ScrollThumbRect(), 3);
|
||||
|
||||
// ── 样式区:五行 + 每行内部的色块/按钮/滑轨/数值 ──────────────────
|
||||
// ⚠️ **只在存在样式编辑目标时登记控件几何**(用户 2026-11-02 报告):
|
||||
// `LayoutStyleControls()` 无条件算出这些矩形,而绘制在“没有目标”时只画占位文字。
|
||||
// 旧实现照样上报 ⇒ 空面板上打开「显示布局边界」会看到一整排并不存在的控件框。
|
||||
const bool hasStyleTarget = HasStyleTarget();
|
||||
if (hasStyleTarget)
|
||||
{
|
||||
for (std::size_t index = 0; index < m_styleRowRects.size(); ++index)
|
||||
{
|
||||
boxes.Add(m_styleRowRects[index], 2);
|
||||
@@ -123,11 +129,17 @@ namespace jdi
|
||||
boxes.Add(m_opacityTrackRect, 3);
|
||||
boxes.Add(m_opacityValueRect, 3);
|
||||
boxes.Add(m_opacityResetRect, 3);
|
||||
}
|
||||
|
||||
// ── 「目标」行(样式区顶部,展示当前选中信号) ────────────────────
|
||||
// 这一行**无论是否选中**都会绘制(未选中时显示占位文本),所以本体总是登记;
|
||||
// 但信号名 / GX 徽标只在实际有目标时存在。
|
||||
boxes.Add(m_targetRowRect, 2);
|
||||
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;
|
||||
|
||||
@@ -55,6 +55,10 @@ namespace jdi
|
||||
// 样式区的可点击控件(「调整」行三个按钮、线宽/透明度数值框)也用手型光标。
|
||||
const float fx = static_cast<float>(x);
|
||||
const float fy = static_cast<float>(y);
|
||||
// ⚠️ 样式区控件只在**存在编辑目标**时才可点(用户 2026-11-02 报告:没有选中信号时,
|
||||
// 鼠标掠过「系数/偏移 | 镜像 | 绝对值」仍会变成手型,而点了什么也不会发生)。
|
||||
if (HasStyleTarget())
|
||||
{
|
||||
// 样式区顶部「目标」行的信号名与 GX 标签:与信号列表行一致,用手型光标提示可交互。
|
||||
UpdateTargetRowGeometry();
|
||||
if ((m_targetNameRect.Width > 0.0f && UiPointInRect(m_targetNameRect, fx, fy)) ||
|
||||
@@ -64,13 +68,15 @@ namespace jdi
|
||||
}
|
||||
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_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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`。
|
||||
@@ -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 「自由标记」面板里被**收起**的信号分组(点击分组栏切换)。
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user