288 信号卡顿根治 + 中心轴面板重做 + 标签页默认状态

一、卡顿真因(实测 29.8ms/帧 → 6.6ms/帧)
1. 中心轴面板高度没有上限:288 个信号时面板高 9996px(绘图区只有 1141px)
   ⇒ PanelRectInsideLayer 恒为假 ⇒ 面板像素缓存一次都存不进去 ⇒ 每帧全量重画。
2. PanelGlobalKey 混入鼠标驱动状态(m_hoverSignal / m_hoverLane / m_hoverCursor /
   CursorTime / EdgeRangeMin/Max)⇒ 鼠标一动键就变 ⇒ 所有面板每帧重画;
   PanelSlotKey 里还会为每个信号调一次 EdgeCenterCountsFor(单帧 288 次查询)。
3. 缓存命中路径的投影(与面板同大的半透明抗锯齿圆角矩形)每帧重画,GDI+ 实测 > 20ms。

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

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

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

四、验证
.preflight/bugcheck.exe 59/59(A1 空闲 0.7ms、A2 平移 2.5ms、A3 双光标 2.4ms、
A4 自由标记 5.2ms、A5 中心轴拖动 6.6ms;B3 真实 WM_TIMER+WM_PAINT 路径;
B5 快速连点与动画反向;C 中心轴面板交互;D 标签页默认状态)
panelcheck 71/71、markercheck 13/13、ringcheck 6/6
make release / make debug 零 warning
This commit is contained in:
DeepSeek-V4.1-Flash committed 2026-11-02 03:33:28 +08:00
1 parent 8271879ede
commit 944c074ab3
11 files changed
+618 -122

No files matched your search

@@ -22,6 +22,12 @@ namespace jdi
tab.SetAllCollapsed(true); tab.SetAllCollapsed(true);
} }
// ⚠️ **新标签页不存在任何被点选的信号**(用户 2026-11-02 要求):
// 选中状态是**全局共享**的(`DataSet::Selected()`),不清掉的话新建出来的标签页会
// 直接带着上一个标签页的选中信号 —— 样式编辑区于是有自己的“目标”,与新页“空白”
// 的预期不符。清空后样式区回到「未选择信号」状态,直到用户手动点选任意信号。
m_dataSet.SetSelected(SignalRef());
m_tabs.push_back(std::move(tab)); m_tabs.push_back(std::move(tab));
SetActiveTab(static_cast<int>(m_tabs.size()) - 1); SetActiveTab(static_cast<int>(m_tabs.size()) - 1);
} }
@@ -34,7 +40,14 @@ namespace jdi
} }
if (m_tabs.size() <= 1) 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; return;
} }
+48
View File
@@ -1097,6 +1097,12 @@ namespace jdi
*/ */
bool SetLaneMeanFilterScroll(int laneIndex, float scroll); bool SetLaneMeanFilterScroll(int laneIndex, float scroll);
/**
* @brief 设置某网格「中心轴边沿计数」面板内容的滚动偏移(客户区像素,0 = 顶部)。
* @return 偏移真的发生变化返回 true。
*/
bool SetLaneEdgeCenterScroll(int laneIndex, float scroll);
/** /**
* @brief 切换某网格「自由标记」面板里某个信号分组的收起状态。 * @brief 切换某网格「自由标记」面板里某个信号分组的收起状态。
* @return 切换后为「已收起」返回 true。 * @return 切换后为「已收起」返回 true。
@@ -2807,12 +2813,28 @@ namespace jdi
SignalRef m_peakDragRef; /**< 正在拖动弱峰滑块的**信号**(每个信号一套参数)。 */ SignalRef m_peakDragRef; /**< 正在拖动弱峰滑块的**信号**(每个信号一套参数)。 */
int m_meanDragLane = -1; /**< 正在拖动「均值滤波」参数滑块的面板所属网格。 */ int m_meanDragLane = -1; /**< 正在拖动「均值滤波」参数滑块的面板所属网格。 */
SignalRef m_meanDragRef; /**< 正在拖动「均值滤波」参数滑块的信号(每信号一套参数)。 */ SignalRef m_meanDragRef; /**< 正在拖动「均值滤波」参数滑块的信号(每信号一套参数)。 */
/**
* @brief 当前参数滑块拖拽的目标是**中心轴边沿计数**面板(而不是均值滤波)。
*
* 「中心轴边沿计数」自 2026-11-02 起与「均值滤波」共用同一套可滚动列表与滑块交互,
* 因此复用 `MEAN_POINTS` / `MEAN_ITERATIONS` / `MEAN_SCROLL` 三个拖拽模式;
* 这个标志说明它们的写入目标是 `Lane::edgeCenterItems` 还是 `Lane::meanFilterItems`。
*/
bool m_meanDragCenter = false;
/** /**
* @brief 正在编辑「均值滤波」某个滑块上限时,输入框内灰色提示用的**当前上限值**。 * @brief 正在编辑「均值滤波」某个滑块上限时,输入框内灰色提示用的**当前上限值**。
* *
* 与 `m_peakEditCurrentMax` 同一语义(输入框以空内容开始,空内容时显示当前上限)。 * 与 `m_peakEditCurrentMax` 同一语义(输入框以空内容开始,空内容时显示当前上限)。
*/ */
int m_meanEditCurrentMax = 0; int m_meanEditCurrentMax = 0;
/**
* @brief 正在编辑「滑块上限」的是**中心轴边沿计数**面板(而不是均值滤波)。
*
* 与 `m_meanDragCenter` 同一思路:两者共用 `MEAN_POINTS_MAX` / `MEAN_ITERATIONS_MAX`
* 两个编辑目标,这个标志说明提交时要写到 `Lane::edgeCenterItems` 还是 `meanFilterItems`。
*/
bool m_editMeanCenter = false;
/** /**
* @brief 正在编辑「迭代次数 N 上限」时,输入框内灰色提示用的**当前上限值**。 * @brief 正在编辑「迭代次数 N 上限」时,输入框内灰色提示用的**当前上限值**。
* *
@@ -2867,6 +2889,32 @@ namespace jdi
/** 「弱峰调整」每行滑块右侧的复位控件是否处于悬停态(用于“进入/离开”时只提示一次)。 */ /** 「弱峰调整」每行滑块右侧的复位控件是否处于悬停态(用于“进入/离开”时只提示一次)。 */
bool m_peakResetHover = false; 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 上一帧鼠标悬停在「均值滤波 / 弱峰调整」面板的哪个**条目栏**上。 * @brief 上一帧鼠标悬停在「均值滤波 / 弱峰调整」面板的哪个**条目栏**上。
* *
+19 -1
View File
@@ -625,8 +625,26 @@ namespace jdi
* 而系统时钟粒度约 15.6ms —— 当帧率高于它时(`PostMessage` 自持重绘会把帧率拉高), * 而系统时钟粒度约 15.6ms —— 当帧率高于它时(`PostMessage` 自持重绘会把帧率拉高),
* 同一 tick 内的多帧**完全不推进**(用户报告:“按住横向光标不动时统计卡在统计中”, * 同一 tick 内的多帧**完全不推进**(用户报告:“按住横向光标不动时统计卡在统计中”,
* “回车确认参数后不移动鼠标就一直停在部分值”)。 * “回车确认参数后不移动鼠标就一直停在部分值”)。
*
* ⚠️⚠️ **这是「非交互帧」的预算**:用户 2026-11-02 报告——288 个信号合并到同一网格、
* 开启中心轴边沿计数后,拖动横向光标每帧要 30ms(实测),界面完全跟不上手。
* 原因就是本预算太大:中心轴每个采样对还要按“均值点数 × 迭代次数”算一次均值窗口,
* 1200000 对 × 11 次浮点运算 ≈ 3 位千万级运算/帧。
* 现在交互期间改用 `kEdgeScanPairsPerFrameInteractive`(见 `Paint` 开头)。
*/ */
inline const std::size_t kEdgeScanPairsPerFrame = 1200000; inline const std::size_t kEdgeScanPairsPerFrame = 400000;
/**
* @brief **交互帧**(正在拖动 / 鼠标刚移动过)允许的边沿统计预算。
*
* 交互时首要目标是跟手:把预算压到非交互帧的 1/5(实测单帧统计开销 ≈6ms 以内),
* 鼠标停下 `kEdgeScanInteractiveIdleMs` 之后自动恢复高预算、快速把统计补齐。
* ⚠️ 不能降到 0 —— 那样“按住横向光标不动”时统计会停在“统计中…”。
*/
inline const std::size_t kEdgeScanPairsPerFrameInteractive = 80000;
/**
* @brief 鼠标停止移动多久之后恢复高统计预算(毫秒)。
*/
inline const std::uint64_t kEdgeScanInteractiveIdleMs = 140;
/** /**
* @brief 每个累积状态每帧的扫描配额下限(防止多个状态时前面的把预算吃光)。 * @brief 每个累积状态每帧的扫描配额下限(防止多个状态时前面的把预算吃光)。
* *
@@ -1414,6 +1414,8 @@ namespace jdi
if (lane.EdgeCenterEnabled(ref)) if (lane.EdgeCenterEnabled(ref))
{ {
lane.RemoveEdgeCenterItem(ref); lane.RemoveEdgeCenterItem(ref);
// 该信号的累积统计状态一并释放(否则会占着 `kEdgeScanStateLimit` 的名额)。
InvalidateEdgeScansFor(ResolveSignal(ref).data, true);
return false; return false;
} }
lane.EnsureEdgeCenterItem(ref); lane.EnsureEdgeCenterItem(ref);
@@ -1809,6 +1811,23 @@ namespace jdi
return true; 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::ToggleLaneFreeMarkerGroup(int laneIndex, const SignalRef& ref) bool PlotView::ToggleLaneFreeMarkerGroup(int laneIndex, const SignalRef& ref)
{ {
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size())) if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
+209 -49
View File
@@ -651,19 +651,25 @@ namespace jdi
target == PlotEditTarget::MEAN_ITERATIONS_MAX) target == PlotEditTarget::MEAN_ITERATIONS_MAX)
{ {
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)]; Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
if (lane.FindMeanFilterItem(m_editMeanRef) == nullptr) const Lane::MeanFilterItem* existing = m_editMeanCenter
? lane.FindEdgeCenterItem(m_editMeanRef)
: lane.FindMeanFilterItem(m_editMeanRef);
if (existing == nullptr)
{ {
return true; return true;
} }
if (parsedOk) if (parsedOk)
{ {
const bool isPoints = (target == PlotEditTarget::MEAN_POINTS_MAX); const bool isPoints = (target == PlotEditTarget::MEAN_POINTS_MAX);
const int minimum = isPoints ? kMeanFilterPointsMin : kMeanFilterIterationsMin; const int minimum =
isPoints ? (m_editMeanCenter ? 0 : kMeanFilterPointsMin) : kMeanFilterIterationsMin;
const int limit = isPoints ? kMeanFilterPointsMaxLimit : kMeanFilterIterationsMaxLimit; const int limit = isPoints ? kMeanFilterPointsMaxLimit : kMeanFilterIterationsMaxLimit;
const long long value = static_cast<long long>(std::llround(parsed)); const long long value = static_cast<long long>(std::llround(parsed));
if (value >= minimum && value <= limit) if (value >= minimum && value <= limit)
{ {
Lane::MeanFilterItem& item = lane.EnsureMeanFilterItem(m_editMeanRef); Lane::MeanFilterItem& item = m_editMeanCenter
? lane.EnsureEdgeCenterItem(m_editMeanRef)
: lane.EnsureMeanFilterItem(m_editMeanRef);
if (isPoints) if (isPoints)
{ {
item.maxPoints = static_cast<int>(value); item.maxPoints = static_cast<int>(value);
@@ -676,6 +682,10 @@ namespace jdi
std::max(minimum, std::min(item.maxIterations, item.iterations)); std::max(minimum, std::min(item.maxIterations, item.iterations));
} }
m_meanCurveCache.clear(); m_meanCurveCache.clear();
if (m_editMeanCenter)
{
InvalidateEdgeScansFor(ResolveSignal(m_editMeanRef).data, true);
}
} }
} }
return true; return true;
@@ -1060,6 +1070,67 @@ namespace jdi
} }
} }
} }
// 「均值滤波 / 中心轴边沿计数」可滚动列表:条目栏(含复选框)→ 手型;
// 滑块轨道 → 左右箭头;数值(可点击改上限)与复位按钮 → 手型;
// 内容区滚动条 → 上下箭头。与「弱峰调整」同一套观感。
if (static_cast<EdgePanelKind>(panelKind) == EdgePanelKind::MEAN ||
static_cast<EdgePanelKind>(panelKind) == EdgePanelKind::CENTER)
{
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)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
if (rowIndex < layout.rowLevelValues.size() &&
layout.rowLevelValues[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowLevelValues[rowIndex], static_cast<float>(x),
static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
if (rowIndex < layout.rowIterValues.size() &&
layout.rowIterValues[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowIterValues[rowIndex], static_cast<float>(x),
static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
if (rowIndex < layout.rowLevelResets.size() &&
layout.rowLevelResets[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowLevelResets[rowIndex], static_cast<float>(x),
static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
if (rowIndex < layout.rowIterResets.size() &&
layout.rowIterResets[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowIterResets[rowIndex], static_cast<float>(x),
static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
if (rowIndex < layout.rowLevelTracks.size() &&
WeakPeakSliderHit(layout.rowLevelTracks[rowIndex], m_scale,
static_cast<float>(x), static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_SIZEWE);
}
if (rowIndex < layout.rowIterTracks.size() &&
WeakPeakSliderHit(layout.rowIterTracks[rowIndex], m_scale,
static_cast<float>(x), static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_SIZEWE);
}
}
if (layout.scrollThumb.Width > 0.0f &&
UiPointInRect(layout.scrollThumb, static_cast<float>(x), static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_SIZENS);
}
}
// 「弱峰调整」的逐信号滑块:轨道 → 左右箭头;迭代次数右侧数值 → 手型(可点击改上限)。 // 「弱峰调整」的逐信号滑块:轨道 → 左右箭头;迭代次数右侧数值 → 手型(可点击改上限)。
if (static_cast<EdgePanelKind>(panelKind) == EdgePanelKind::PEAK) if (static_cast<EdgePanelKind>(panelKind) == EdgePanelKind::PEAK)
{ {
@@ -1164,11 +1235,32 @@ namespace jdi
return nullptr; 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) bool PlotView::OnMouseMove(int x, int y)
{ {
const bool mouseMoved = (x != m_mouseX) || (y != m_mouseY); const bool mouseMoved = (x != m_mouseX) || (y != m_mouseY);
m_mouseX = x; m_mouseX = x;
m_mouseY = y; m_mouseY = y;
// 交互活动时间戳:`Paint` 用它决定边沿统计本帧用「交互预算」还是「静止预算」。
// 只有真正的移动才更新 —— 悬停不动时统计应当全速推进。
if (mouseMoved)
{
m_lastInputActivityMs = GetTickCount64();
}
if (!HasDrawableContent()) if (!HasDrawableContent())
{ {
@@ -1441,21 +1533,26 @@ namespace jdi
case PlotDragMode::MEAN_POINTS: case PlotDragMode::MEAN_POINTS:
case PlotDragMode::MEAN_ITERATIONS: case PlotDragMode::MEAN_ITERATIONS:
{ {
// 「均值滤波」某信号的参数滑块:量程 = 下限 … 该信号自己的上限。 // 「均值滤波 / 中心轴边沿计数」某信号的参数滑块:量程 = 下限 … 该信号自己的上限。
if (m_tab == nullptr || m_meanDragLane < 0 || if (m_tab == nullptr || m_meanDragLane < 0 ||
m_meanDragLane >= static_cast<int>(m_tab->Lanes().size())) m_meanDragLane >= static_cast<int>(m_tab->Lanes().size()))
{ {
break; break;
} }
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(m_meanDragLane)]; Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(m_meanDragLane)];
if (lane.FindMeanFilterItem(m_meanDragRef) == nullptr) const Lane::MeanFilterItem* existingItem = m_meanDragCenter
? lane.FindEdgeCenterItem(m_meanDragRef)
: lane.FindMeanFilterItem(m_meanDragRef);
if (existingItem == nullptr)
{ {
break; break;
} }
Lane::MeanFilterItem& item = lane.EnsureMeanFilterItem(m_meanDragRef); Lane::MeanFilterItem& item = m_meanDragCenter
? lane.EnsureEdgeCenterItem(m_meanDragRef)
: lane.EnsureMeanFilterItem(m_meanDragRef);
const bool pointsMode = (m_dragMode == PlotDragMode::MEAN_POINTS); const bool pointsMode = (m_dragMode == PlotDragMode::MEAN_POINTS);
const EdgePanelLayout layout = const EdgePanelLayout layout = EdgePanelLayoutFor(
EdgePanelLayoutFor(m_meanDragLane, EdgePanelKind::MEAN); m_meanDragLane, m_meanDragCenter ? EdgePanelKind::CENTER : EdgePanelKind::MEAN);
int rowIndex = -1; int rowIndex = -1;
for (std::size_t candidate = 0; candidate < layout.rowRefs.size(); ++candidate) for (std::size_t candidate = 0; candidate < layout.rowRefs.size(); ++candidate)
{ {
@@ -1480,13 +1577,15 @@ namespace jdi
UiSliderRatioFromX(tracks[static_cast<std::size_t>(rowIndex)], fx); UiSliderRatioFromX(tracks[static_cast<std::size_t>(rowIndex)], fx);
if (pointsMode) if (pointsMode)
{ {
const int maximum = std::max( // ⚠️ 下限:均值滤波 = 1(用户 2026-10-30 要求不能为 0);中心轴 = 0(0 = 不平滑)。
kMeanFilterPointsMin, std::min(kMeanFilterPointsMaxLimit, item.maxPoints)); const int floorValue = m_meanDragCenter ? 0 : kMeanFilterPointsMin;
item.points = std::max( const int maximum =
kMeanFilterPointsMin, std::max(floorValue, std::min(kMeanFilterPointsMaxLimit, item.maxPoints));
std::min(maximum, item.points =
kMeanFilterPointsMin + std::max(floorValue,
static_cast<int>(std::lround(ratio * (maximum - kMeanFilterPointsMin))))); std::min(maximum,
floorValue +
static_cast<int>(std::lround(ratio * (maximum - floorValue)))));
} }
else else
{ {
@@ -1501,6 +1600,11 @@ namespace jdi
ratio * (maximum - kMeanFilterIterationsMin))))); ratio * (maximum - kMeanFilterIterationsMin)))));
} }
m_meanCurveCache.clear(); m_meanCurveCache.clear();
// 中心轴统计依赖参数 ⇒ 参数一变必须定向作废该信号的累积状态。
if (m_meanDragCenter)
{
InvalidateEdgeScansFor(ResolveSignal(m_meanDragRef).data, true);
}
m_dragMoved = true; m_dragMoved = true;
return true; return true;
} }
@@ -1639,14 +1743,14 @@ namespace jdi
} }
case PlotDragMode::MEAN_SCROLL: case PlotDragMode::MEAN_SCROLL:
{ {
// 「均值滤波」面板内容滚动条:按滑块在轨道里的位置换算滚动偏移。 // 「均值滤波 / 中心轴边沿计数」面板内容滚动条:按滑块在轨道里的位置换算滚动偏移。
if (m_tab == nullptr || m_markerScrollDragLane < 0 || if (m_tab == nullptr || m_markerScrollDragLane < 0 ||
m_markerScrollDragLane >= static_cast<int>(m_tab->Lanes().size())) m_markerScrollDragLane >= static_cast<int>(m_tab->Lanes().size()))
{ {
break; break;
} }
const EdgePanelLayout layout = const EdgePanelLayout layout = EdgePanelLayoutFor(
EdgePanelLayoutFor(m_markerScrollDragLane, EdgePanelKind::MEAN); m_markerScrollDragLane, m_meanDragCenter ? EdgePanelKind::CENTER : EdgePanelKind::MEAN);
const float travel = layout.scrollTrack.Height - layout.scrollThumb.Height; const float travel = layout.scrollTrack.Height - layout.scrollThumb.Height;
const float maxScroll = const float maxScroll =
std::max(0.0f, layout.markerContentHeight - layout.content.Height); std::max(0.0f, layout.markerContentHeight - layout.content.Height);
@@ -1659,7 +1763,11 @@ namespace jdi
std::min(static_cast<float>(y) - m_markerScrollGrabOffset, std::min(static_cast<float>(y) - m_markerScrollGrabOffset,
layout.scrollTrack.Y + travel)); layout.scrollTrack.Y + travel));
const float ratio = (thumbTop - layout.scrollTrack.Y) / travel; const float ratio = (thumbTop - layout.scrollTrack.Y) / travel;
if (SetLaneMeanFilterScroll(m_markerScrollDragLane, ratio * maxScroll)) const bool scrolled =
m_meanDragCenter
? SetLaneEdgeCenterScroll(m_markerScrollDragLane, ratio * maxScroll)
: SetLaneMeanFilterScroll(m_markerScrollDragLane, ratio * maxScroll);
if (scrolled)
{ {
m_dragMoved = true; m_dragMoved = true;
return true; return true;
@@ -1827,17 +1935,18 @@ namespace jdi
hoverPanelKind); hoverPanelKind);
} }
// 「均值滤波 / 弱峰调整」面板**条目栏**的悬停 → 高亮该信号的预览曲线 // 「均值滤波 / 弱峰调整 / 中心轴边沿计数」面板**条目栏**的悬停 → 高亮该条目
//(用户 2026-10-30 要求:"鼠标处于条目栏上方时,对应的预览曲线需要高亮提示")。 //(用户 2026-10-30 要求:"鼠标处于条目栏上方时,对应的预览曲线需要高亮提示";
// 中心轴边沿计数自 2026-11-02 起同样有条目栏,条目本身也会叠一层强调色)。
{ {
int hoverLane = -1; int hoverLane = -1;
SignalRef hoverRef; SignalRef hoverRef;
const EdgePanelKind hoverKind = static_cast<EdgePanelKind>(hoverPanelKind);
if (overEdgePanel && m_tab != nullptr && panelLane >= 0 && if (overEdgePanel && m_tab != nullptr && panelLane >= 0 &&
(static_cast<EdgePanelKind>(hoverPanelKind) == EdgePanelKind::MEAN || (hoverKind == EdgePanelKind::MEAN || hoverKind == EdgePanelKind::PEAK ||
static_cast<EdgePanelKind>(hoverPanelKind) == EdgePanelKind::PEAK)) hoverKind == EdgePanelKind::CENTER))
{ {
const EdgePanelLayout layout = const EdgePanelLayout layout = EdgePanelLayoutFor(panelLane, hoverKind);
EdgePanelLayoutFor(panelLane, static_cast<EdgePanelKind>(hoverPanelKind));
if (UiPointInRect(layout.content, static_cast<float>(x), static_cast<float>(y))) if (UiPointInRect(layout.content, static_cast<float>(x), static_cast<float>(y)))
{ {
for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex)
@@ -2236,14 +2345,29 @@ namespace jdi
m_tab != nullptr) m_tab != nullptr)
{ {
const bool meanMode = (kind == EdgePanelKind::MEAN); const bool meanMode = (kind == EdgePanelKind::MEAN);
const bool centerMode = (kind == EdgePanelKind::CENTER);
const bool boostMode = (kind == EdgePanelKind::PEAK); const bool boostMode = (kind == EdgePanelKind::PEAK);
// 「均值滤波」与「中心轴边沿计数」自 2026-11-02 起共用同一套**可滚动列表**交互
//(信号色叠底条目栏 + 右侧复选框 + 展开后的两行滑块)。
const bool listMode = (meanMode || centerMode);
// 「均值滤波」:组头三角(展开 / 收起参数行)+ 两行参数滑块 // 「均值滤波」:组头三角(展开 / 收起参数行)+ 两行参数滑块
//(轨道拖动 / 点击数值改上限 / 右侧复位按钮)。 //(轨道拖动 / 点击数值改上限 / 右侧复位按钮)。
// ⚠️ 所有命中都要求落在**内容区**之内:滚动到可视范围之外的行仍保留矩形, // ⚠️ 所有命中都要求落在**内容区**之内:滚动到可视范围之外的行仍保留矩形,
// 若不加这条限制,它们会抢走底部「构建为新信号」按钮的点击。 // 若不加这条限制,它们会抢走底部「构建为新信号」按钮的点击。
if (meanMode) if (listMode)
{ {
Lane& meanLane = m_tab->Lanes()[static_cast<std::size_t>(index)]; 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 && if (layout.scrollThumb.Width > 0.0f &&
UiPointInRect(layout.scrollThumb, fx, fy)) UiPointInRect(layout.scrollThumb, fx, fy))
@@ -2251,6 +2375,7 @@ namespace jdi
m_dragMode = PlotDragMode::MEAN_SCROLL; m_dragMode = PlotDragMode::MEAN_SCROLL;
m_markerScrollDragLane = index; m_markerScrollDragLane = index;
m_markerScrollGrabOffset = fy - layout.scrollThumb.Y; m_markerScrollGrabOffset = fy - layout.scrollThumb.Y;
m_meanDragCenter = centerMode;
return true; return true;
} }
const bool insideContent = const bool insideContent =
@@ -2267,10 +2392,10 @@ namespace jdi
const bool hasParams = const bool hasParams =
rowIndex < layout.rowLevelTracks.size() && rowIndex < layout.rowLevelTracks.size() &&
layout.rowLevelTracks[rowIndex].Width > 0.0f && layout.rowLevelTracks[rowIndex].Width > 0.0f &&
meanLane.FindMeanFilterItem(ref) != nullptr; findItem(ref) != nullptr;
if (hasParams) if (hasParams)
{ {
Lane::MeanFilterItem& item = meanLane.EnsureMeanFilterItem(ref); Lane::MeanFilterItem& item = ensureItem(ref);
// 复位:均值点数 → 默认值(同时把滑块上限恢复为默认上限)。 // 复位:均值点数 → 默认值(同时把滑块上限恢复为默认上限)。
if (rowIndex < layout.rowLevelResets.size() && if (rowIndex < layout.rowLevelResets.size() &&
layout.rowLevelResets[rowIndex].Width > 0.0f && layout.rowLevelResets[rowIndex].Width > 0.0f &&
@@ -2279,6 +2404,10 @@ namespace jdi
item.points = kMeanFilterPointsDefault; item.points = kMeanFilterPointsDefault;
item.maxPoints = kMeanFilterPointsDefaultMax; item.maxPoints = kMeanFilterPointsDefaultMax;
m_meanCurveCache.clear(); m_meanCurveCache.clear();
if (centerMode)
{
InvalidateEdgeScansFor(ResolveSignal(ref).data, true);
}
m_dragMode = PlotDragMode::NONE; m_dragMode = PlotDragMode::NONE;
return true; return true;
} }
@@ -2290,6 +2419,10 @@ namespace jdi
item.iterations = kMeanFilterIterationsMin; item.iterations = kMeanFilterIterationsMin;
item.maxIterations = kMeanFilterIterationsDefaultMax; item.maxIterations = kMeanFilterIterationsDefaultMax;
m_meanCurveCache.clear(); m_meanCurveCache.clear();
if (centerMode)
{
InvalidateEdgeScansFor(ResolveSignal(ref).data, true);
}
m_dragMode = PlotDragMode::NONE; m_dragMode = PlotDragMode::NONE;
return true; return true;
} }
@@ -2300,6 +2433,7 @@ namespace jdi
{ {
m_editMeanRef = ref; m_editMeanRef = ref;
m_meanEditCurrentMax = item.maxPoints; m_meanEditCurrentMax = item.maxPoints;
m_editMeanCenter = centerMode;
const gd::RectF& track = layout.rowLevelTracks[rowIndex]; const gd::RectF& track = layout.rowLevelTracks[rowIndex];
const gd::RectF& value = layout.rowLevelValues[rowIndex]; const gd::RectF& value = layout.rowLevelValues[rowIndex];
const gd::RectF rowRect(track.X, value.Y, const gd::RectF rowRect(track.X, value.Y,
@@ -2317,6 +2451,7 @@ namespace jdi
{ {
m_editMeanRef = ref; m_editMeanRef = ref;
m_meanEditCurrentMax = item.maxIterations; m_meanEditCurrentMax = item.maxIterations;
m_editMeanCenter = centerMode;
const gd::RectF& track = layout.rowIterTracks[rowIndex]; const gd::RectF& track = layout.rowIterTracks[rowIndex];
const gd::RectF& value = layout.rowIterValues[rowIndex]; const gd::RectF& value = layout.rowIterValues[rowIndex];
const gd::RectF rowRect(track.X, value.Y, const gd::RectF rowRect(track.X, value.Y,
@@ -2331,21 +2466,23 @@ namespace jdi
if (rowIndex < layout.rowLevelTracks.size() && if (rowIndex < layout.rowLevelTracks.size() &&
WeakPeakSliderHit(layout.rowLevelTracks[rowIndex], m_scale, fx, fy)) WeakPeakSliderHit(layout.rowLevelTracks[rowIndex], m_scale, fx, fy))
{ {
const int maximum = std::max( const int floorValue = centerMode ? 0 : kMeanFilterPointsMin;
kMeanFilterPointsMin, const int maximum =
std::min(kMeanFilterPointsMaxLimit, item.maxPoints)); std::max(floorValue, std::min(kMeanFilterPointsMaxLimit, item.maxPoints));
m_dragMode = PlotDragMode::MEAN_POINTS; m_dragMode = PlotDragMode::MEAN_POINTS;
m_meanDragLane = index; m_meanDragLane = index;
m_meanDragRef = ref; m_meanDragRef = ref;
m_meanDragCenter = centerMode;
const double ratio = UiSliderRatioFromX(layout.rowLevelTracks[rowIndex], fx); const double ratio = UiSliderRatioFromX(layout.rowLevelTracks[rowIndex], fx);
item.points = std::max( item.points = std::max(
kMeanFilterPointsMin, floorValue,
std::min(maximum, std::min(maximum,
kMeanFilterPointsMin + floorValue + static_cast<int>(std::lround(ratio * (maximum - floorValue)))));
static_cast<int>(std::lround(
ratio *
(maximum - kMeanFilterPointsMin)))));
m_meanCurveCache.clear(); m_meanCurveCache.clear();
if (centerMode)
{
InvalidateEdgeScansFor(ResolveSignal(ref).data, true);
}
return true; return true;
} }
// 拖动「迭代次数」滑块。 // 拖动「迭代次数」滑块。
@@ -2358,15 +2495,19 @@ namespace jdi
m_dragMode = PlotDragMode::MEAN_ITERATIONS; m_dragMode = PlotDragMode::MEAN_ITERATIONS;
m_meanDragLane = index; m_meanDragLane = index;
m_meanDragRef = ref; m_meanDragRef = ref;
m_meanDragCenter = centerMode;
const double ratio = UiSliderRatioFromX(layout.rowIterTracks[rowIndex], fx); const double ratio = UiSliderRatioFromX(layout.rowIterTracks[rowIndex], fx);
item.iterations = std::max( item.iterations = std::max(
kMeanFilterIterationsMin, kMeanFilterIterationsMin,
std::min(maximum, std::min(maximum,
kMeanFilterIterationsMin + kMeanFilterIterationsMin +
static_cast<int>(std::lround( static_cast<int>(std::lround(
ratio * ratio * (maximum - kMeanFilterIterationsMin)))));
(maximum - kMeanFilterIterationsMin)))));
m_meanCurveCache.clear(); m_meanCurveCache.clear();
if (centerMode)
{
InvalidateEdgeScansFor(ResolveSignal(ref).data, true);
}
return true; return true;
} }
} }
@@ -2385,11 +2526,18 @@ namespace jdi
rowIndex < layout.rowChecks.size() && rowIndex < layout.rowChecks.size() &&
layout.rowChecks[rowIndex].Width > 0.0f && layout.rowChecks[rowIndex].Width > 0.0f &&
UiPointInRect(layout.rowChecks[rowIndex], fx, fy); UiPointInRect(layout.rowChecks[rowIndex], fx, fy);
Lane::MeanFilterItem* existing = const_cast<Lane::MeanFilterItem*>( Lane::MeanFilterItem* existing =
meanLane.FindMeanFilterItem(ref)); const_cast<Lane::MeanFilterItem*>(findItem(ref));
if (onCheckBox) if (onCheckBox)
{ {
ToggleLaneMeanFilterSignal(index, ref); if (centerMode)
{
ToggleLaneEdgeCenterSignal(index, ref);
}
else
{
ToggleLaneMeanFilterSignal(index, ref);
}
m_meanRowHoverLane = -1; m_meanRowHoverLane = -1;
m_meanRowHoverRef = SignalRef(); m_meanRowHoverRef = SignalRef();
} }
@@ -3057,9 +3205,10 @@ namespace jdi
// 弱峰滑块拖拽结束:清掉目标(避免留下上一轮的信号引用)。 // 弱峰滑块拖拽结束:清掉目标(避免留下上一轮的信号引用)。
m_peakDragLane = -1; m_peakDragLane = -1;
m_peakDragRef = SignalRef(); m_peakDragRef = SignalRef();
// 「均值滤波」参数滑块拖拽结束:同样清掉目标。 // 「均值滤波 / 中心轴边沿计数」参数滑块拖拽结束:同样清掉目标。
m_meanDragLane = -1; m_meanDragLane = -1;
m_meanDragRef = SignalRef(); m_meanDragRef = SignalRef();
m_meanDragCenter = false;
// 「标记透明度」滑块 / 面板内容滚动条拖拽结束。 // 「标记透明度」滑块 / 面板内容滚动条拖拽结束。
m_markerOpacityDragLane = -1; m_markerOpacityDragLane = -1;
m_markerScrollDragLane = -1; m_markerScrollDragLane = -1;
@@ -3111,10 +3260,11 @@ namespace jdi
panelLane, panelKind)) panelLane, panelKind))
{ {
const EdgePanelKind hoveredKind = static_cast<EdgePanelKind>(panelKind); const EdgePanelKind hoveredKind = static_cast<EdgePanelKind>(panelKind);
// 「均值滤波」面板:内容区超高时用滚轮翻页(上百个信号合并时必备)。 // 「均值滤波 / 中心轴边沿计数」面板:内容区超高时用滚轮翻页
if (hoveredKind == EdgePanelKind::MEAN) //(上百个信号合并时必备)。
if (hoveredKind == EdgePanelKind::MEAN || hoveredKind == EdgePanelKind::CENTER)
{ {
const EdgePanelLayout layout = EdgePanelLayoutFor(panelLane, EdgePanelKind::MEAN); const EdgePanelLayout layout = EdgePanelLayoutFor(panelLane, hoveredKind);
if (layout.content.Width > 0.0f && if (layout.content.Width > 0.0f &&
UiPointInRect(layout.content, static_cast<float>(m_mouseX), UiPointInRect(layout.content, static_cast<float>(m_mouseX),
static_cast<float>(m_mouseY))) static_cast<float>(m_mouseY)))
@@ -3123,11 +3273,21 @@ namespace jdi
std::max(0.0f, layout.markerContentHeight - layout.content.Height); std::max(0.0f, layout.markerContentHeight - layout.content.Height);
if (maxScroll > 0.0f) if (maxScroll > 0.0f)
{ {
const float current = const Lane& wheelLane =
Tab().Lanes()[static_cast<std::size_t>(panelLane)].meanFilterScroll; Tab().Lanes()[static_cast<std::size_t>(panelLane)];
const float current = (hoveredKind == EdgePanelKind::CENTER)
? wheelLane.edgeCenterScroll
: wheelLane.meanFilterScroll;
const float next = current - static_cast<float>(notches * 40.0 * m_scale); const float next = current - static_cast<float>(notches * 40.0 * m_scale);
SetLaneMeanFilterScroll(panelLane, const float target = std::max(0.0f, std::min(next, maxScroll));
std::max(0.0f, std::min(next, maxScroll))); if (hoveredKind == EdgePanelKind::CENTER)
{
SetLaneEdgeCenterScroll(panelLane, target);
}
else
{
SetLaneMeanFilterScroll(panelLane, target);
}
return true; return true;
} }
} }
+142 -31
View File
@@ -815,8 +815,18 @@ namespace jdi
// 就会在同一系统 tick 内连画多帧而**完全不推进**统计 —— 用户报告 // 就会在同一系统 tick 内连画多帧而**完全不推进**统计 —— 用户报告
// “按住横向光标不动时统计卡住不更新”“回车确认参数后不移动鼠标就停在部分值”。 // “按住横向光标不动时统计卡住不更新”“回车确认参数后不移动鼠标就停在部分值”。
++m_edgeScanFrame; ++m_edgeScanFrame;
m_edgeScanBudget = kEdgeScanPairsPerFrame; // ⚠️⚠️ **交互帧必须用小预算**(用户 2026-11-02 报告):288 个信号合并到同一网格、
// 开启中心轴边沿计数后,拖动横向光标每帧要 30ms,界面完全跟不上手。中心轴每个采样对
// 还要按「均值点数 × 迭代次数」算一次均值窗口 ⇒ 120 万对 ≈ 3 千万次浮点运算/帧。
// 现在:正在拖拽、或鼠标刚刚移动过(`kEdgeScanInteractiveIdleMs` 内)⇒ 用交互预算
// (1/5),优先保证界面跟手;鼠标停下约 0.14s 后自动恢复高预算、把统计快速补齐。
// ⚠️⚠️ **交互帧必须用小预算**(用户 2026-11-02 报告):288 个信号合并到同一网格、
// 开启中心轴边沿计数后,拖动横向光标每帧要 30ms,界面完全跟不上手。中心轴每个采样对
// 还要按「均值点数 × 迭代次数」算一次均值窗口 ⇒ 120 万对 ≈ 3 千万次浮点运算/帧。
// 现在:正在拖拽、或鼠标刚刚移动过(`kEdgeScanInteractiveIdleMs` 内)⇒ 用交互预算
// (1/5),优先保证界面跟手;鼠标停下约 0.14s 后自动恢复高预算、把统计快速补齐。
m_edgeScanBudget =
InputIsInteractive() ? kEdgeScanPairsPerFrameInteractive : kEdgeScanPairsPerFrame;
// 双缓冲:清掉上一帧钉住的曲线条目(本帧会按需重新钉住),并清理已被更新条目覆盖 / // 双缓冲:清掉上一帧钉住的曲线条目(本帧会按需重新钉住),并清理已被更新条目覆盖 /
// 超出内存预算的旧条目。此刻没有任何条目被钉住,回收是安全的。 // 超出内存预算的旧条目。此刻没有任何条目被钉住,回收是安全的。
m_curvePins.clear(); m_curvePins.clear();
@@ -3483,33 +3493,89 @@ namespace jdi
mix(static_cast<std::uint64_t>(m_laneDragFrom + 2)); mix(static_cast<std::uint64_t>(m_laneDragFrom + 2));
mix(static_cast<std::uint64_t>(m_laneDragTarget + 2)); mix(static_cast<std::uint64_t>(m_laneDragTarget + 2));
mix(static_cast<std::uint64_t>(m_laneDragMouseY + 4096)); mix(static_cast<std::uint64_t>(m_laneDragMouseY + 4096));
mix(static_cast<std::uint64_t>(m_hoverLane + 2)); // ⚠️⚠️ **交互期间冻结全部“随鼠标变化”的项**(用户 2026-11-02 报告“移动鼠标/拖动光标卡顿”):
mix(m_cursorHover ? 1ull : 0ull); // 本键是**全部**面板像素缓存的总开关(`panelGlobalSame`)—— 只要它每帧变,所有面板都会
mix(static_cast<std::uint64_t>(m_hoverCursor + 2)); // 重画。鼠标在绘图区移动时 `m_hoverSignal`(甚至 `m_hoverLane` / `m_hoverCursor`)会不停
// 变化,于是“移动鼠标 → 每帧重画全部面板(288 个信号的中心轴面板 22ms)”成了死循环。
// 这些叠加层都画在**面板之下**、面板本身不透明且贴回时只贴圆角形状之内,因此交互期间
// 直接复用面板像素是安全的;鼠标停下后键自动恢复,面板重画一次即可。
const bool interactivePanelKey = InputIsInteractive();
if (interactivePanelKey)
{
mix(0x9E3779B9ull);
mix(0x9E3779B9ull);
}
else
{
mix(static_cast<std::uint64_t>(m_hoverLane + 2));
mix(m_cursorHover ? 1ull : 0ull);
mix(static_cast<std::uint64_t>(m_hoverCursor + 2));
}
mix(static_cast<std::uint64_t>(m_activeCursor)); mix(static_cast<std::uint64_t>(m_activeCursor));
mix(m_dragMoved ? 1ull : 0ull); mix(m_dragMoved ? 1ull : 0ull);
mixRect(m_rubberRect); mixRect(m_rubberRect);
mixRef(m_hoverSignal); if (interactivePanelKey)
mixRef(m_hoverLegend); {
mix(0x9E3779B9ull);
mix(0x9E3779B9ull);
}
else
{
mixRef(m_hoverSignal);
mixRef(m_hoverLegend);
}
mixRef(m_dragSignal); mixRef(m_dragSignal);
mixRef(SelectedRef()); mixRef(SelectedRef());
mix(static_cast<std::uint64_t>(m_edgePanelDragLane + 2)); mix(static_cast<std::uint64_t>(m_edgePanelDragLane + 2));
mix(static_cast<std::uint64_t>(m_edgePanelDragKind)); mix(static_cast<std::uint64_t>(m_edgePanelDragKind));
// ⚠️ 「均值滤波 / 中心轴边沿计数」**条目栏的悬停高亮**必须**始终**参与全局键:
// 它直接改变面板像素(行头叠一层强调色),而且只在鼠标真正进入/离开某一行时才变化。
// 若跟着上面那些项一起在交互期间冻结,拖动鼠标到条目栏上高亮就不会出现(用户要求
// “鼠标处于条目栏上方时对应曲线/条目需要高亮提示”)。
mix(static_cast<std::uint64_t>(m_meanRowHoverLane + 2));
mixRef(m_meanRowHoverRef);
// 时间光标与统计区间:面板标题的时间后缀、以及「中心轴」行的上升/下降沿数字都依赖它们。 // 时间光标与统计区间:面板标题的时间后缀、以及「中心轴」行的上升/下降沿数字都依赖它们。
// ⚠️⚠️ **交互期间必须用固定占位值**:拖动光标时时间每帧都在变,键一变就要重画全部面板。
mix(static_cast<std::uint64_t>(Tab().CursorMode())); mix(static_cast<std::uint64_t>(Tab().CursorMode()));
mixDouble(Tab().CursorTime()); if (interactivePanelKey)
mixDouble(Tab().CursorTimeAlt()); {
mixDouble(0.0);
mixDouble(0.0);
}
else
{
mixDouble(Tab().CursorTime());
mixDouble(Tab().CursorTimeAlt());
}
mix(EdgeRangeActive() ? 1ull : 0ull); mix(EdgeRangeActive() ? 1ull : 0ull);
mixDouble(EdgeRangeMin()); if (interactivePanelKey)
mixDouble(EdgeRangeMax()); {
// 统计区间来自选区时不随鼠标变化;但若它来自光标就会每帧变 —— 一并冻结。
mixDouble(0.0);
mixDouble(0.0);
}
else
{
mixDouble(EdgeRangeMin());
mixDouble(EdgeRangeMax());
}
// 跟随鼠标的叠加层(悬停光晕 / 图例半高亮 / 信号拖拽虚影 / 提示动画)位置随时在变: // 跟随鼠标的叠加层(悬停光晕 / 图例半高亮 / 信号拖拽虚影 / 提示动画)位置随时在变:
// 只要它存在,就把所有面板缓存作废,避免它的残影被一起缓存进面板像素。 // 只要它存在,就把所有面板缓存作废,避免它的残影被一起缓存进面板像素。
//
// ⚠️⚠️ **交互期间必须忽略它**(用户 2026-11-02 报告“移动鼠标/拖动光标卡顿”):
// 鼠标在绘图区里移动时 `m_hoverSignal` 常常有效(悬停在某条曲线上),这里就会把
// **鼠标坐标**混进全局键 ⇒ 键每帧都变 ⇒ 全部面板每帧重画。288 个信号的中心轴面板
// 一块就要 ~23ms(实测:面板开 27.6ms / 面板关 4.3ms)。
// 而悬停光晕、拖拽虚影都画在**面板之下**,面板本身不透明且贴回时只贴圆角形状之内
// (见下面的 `CreateRoundRectRgn` 裁剪 + 每帧重画投影),因此交互期间直接复用面板像素
// 是安全的;鼠标停下后键自动恢复,面板重画一次即可。
const bool mouseDrivenOverlay = const bool mouseDrivenOverlay =
m_hoverSignal.IsValid() || m_hoverLegend.IsValid() || !interactivePanelKey &&
m_dragMode == PlotDragMode::MOVE_SIGNAL || m_dragMode == PlotDragMode::RUBBER_SELECT || (m_hoverSignal.IsValid() || m_hoverLegend.IsValid() ||
HintNeedsAnimation(); m_dragMode == PlotDragMode::MOVE_SIGNAL || m_dragMode == PlotDragMode::RUBBER_SELECT ||
HintNeedsAnimation());
mix(mouseDrivenOverlay ? 1ull : 0ull); mix(mouseDrivenOverlay ? 1ull : 0ull);
if (mouseDrivenOverlay) if (mouseDrivenOverlay)
{ {
@@ -3717,7 +3783,17 @@ namespace jdi
// 才会显示出早已算完的正确结果。 // 才会显示出早已算完的正确结果。
// ⇒ 把该面板当前要显示的 (上升沿, 下降沿, 是否扫完) 一并 mix 进去:推进会让键变化 ⇒ // ⇒ 把该面板当前要显示的 (上升沿, 下降沿, 是否扫完) 一并 mix 进去:推进会让键变化 ⇒
// 面板重绘 ⇒ 显示随进度更新,直到扫完(扫完后数字不再变,重新命中缓存)。 // 面板重绘 ⇒ 显示随进度更新,直到扫完(扫完后数字不再变,重新命中缓存)。
if (kind == EdgePanelKind::COUNT) //
// ⚠️⚠️ **但交互期间必须跳过这一整段**(用户 2026-11-02 报告“拖动横向光标卡顿”):
// * 计算这一段本身要为**每个信号**调一次 `EdgeCountsFor` / `EdgeCenterCountsFor`
// (含区间边界块扫描)—— 288 个信号时单帧 288 次查询;
// * 而且中间数字每帧都在变 ⇒ 键每帧都变 ⇒ 面板像素缓存**永远不命中**,
// 于是每帧都要把整块面板(288 行 × 十来个 GDI+ 原语)重画一遍 ⇒ 实测 30ms/帧。
// 拖动/鼠标移动期间数字本来就在飞速重算,**显示中间值毫无意义**;此时让键保持稳定
// (用一个固定常量占位)⇒ 面板直接贴上一帧像素 ⇒ 真正跟手。鼠标停下
// `kEdgeScanInteractiveIdleMs` 之后恢复完整键 ⇒ 面板重画并显示最终数字。
const bool interactiveKey = InputIsInteractive();
if (kind == EdgePanelKind::COUNT && !interactiveKey)
{ {
for (const SignalRef& ref : lane.signals) for (const SignalRef& ref : lane.signals)
{ {
@@ -3737,7 +3813,7 @@ namespace jdi
mix(complete ? 1ull : 0ull); mix(complete ? 1ull : 0ull);
} }
} }
else if (kind == EdgePanelKind::CENTER) else if (kind == EdgePanelKind::CENTER && !interactiveKey)
{ {
for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex)
{ {
@@ -4330,6 +4406,9 @@ namespace jdi
(m_editTarget == PlotEditTarget::NONE) && EnsurePanelCache(); (m_editTarget == PlotEditTarget::NONE) && EnsurePanelCache();
const bool panelGlobalSame = const bool panelGlobalSame =
panelCacheReady && m_panelValid && panelGlobalKey == m_panelGlobalKey; panelCacheReady && m_panelValid && panelGlobalKey == m_panelGlobalKey;
// 交互帧(拖动 / 鼠标刚移动)的标志:缓存命中的贴图分支据此跳过昂贵的投影重画
//(见下面 `UiFillRoundRect` 的说明)。
const bool interactiveFrame = InputIsInteractive();
// ⚠️ **按层序从下往上绘制**(与命中测试、像素缓存的层叠顺序完全一致): // ⚠️ **按层序从下往上绘制**(与命中测试、像素缓存的层叠顺序完全一致):
// `m_panelOrder` 队尾 = 最上层 = 最后绘制。 // `m_panelOrder` 队尾 = 最上层 = 最后绘制。
SyncPanelOrder(); SyncPanelOrder();
@@ -4399,12 +4478,13 @@ namespace jdi
const bool meanFilter = (kind == EdgePanelKind::MEAN); const bool meanFilter = (kind == EdgePanelKind::MEAN);
const bool weakPeak = (kind == EdgePanelKind::PEAK); const bool weakPeak = (kind == EdgePanelKind::PEAK);
const bool freeMarker = (kind == EdgePanelKind::MARK); const bool freeMarker = (kind == EdgePanelKind::MARK);
// ⚠️ `itemized` 只表示**旧的逐条目排布**(中心轴 / 弱峰调整)。 // ⚠️ `itemized` 只表示**旧的逐条目排布**(现在只剩「弱峰调整」)。
// 「均值滤波」自 2026-10-30 起有自己的**可滚动分支**(`if (meanFilter)` 内), // 「均值滤波」自 2026-10-30 起、**「中心轴边沿计数」自 2026-11-02 起**都有自己的
// 若它仍被算作 `itemized`,旧分支会**再画一遍**每行的名称 + 复选框 —— // **可滚动分支**(`if (listStyle)` 内)。若它们仍被算作 `itemized`,旧分支会
// 且旧分支**没有任何裁切**,滚动时这些元素就会画到标题栏上 // **再画一遍**每行的名称 + 复选框 —— 且旧分支**没有任何裁切**,滚动时这些元素就会
//(用户 2026-10-30 报告“元素超出浮动面板”的根因)。 // 画到标题栏上(用户 2026-10-30 报告“元素超出浮动面板”的根因)。
const bool itemized = (centerAxis || weakPeak); const bool listStyle = (meanFilter || centerAxis);
const bool itemized = weakPeak;
const bool hasButton = (meanFilter || weakPeak); const bool hasButton = (meanFilter || weakPeak);
{ {
if (index >= count) if (index >= count)
@@ -4476,7 +4556,13 @@ namespace jdi
// (否则平移会重画全部面板),于是整块贴回时就把旧波形"复印"了一圈。 // (否则平移会重画全部面板),于是整块贴回时就把旧波形"复印"了一圈。
// 现在:① 先把**投影**按本帧重新画一遍(它在圆角形状之外,不能靠缓存); // 现在:① 先把**投影**按本帧重新画一遍(它在圆角形状之外,不能靠缓存);
// ② 用圆角区域裁剪后只 BitBlt 面板本体(圆角之外仍是本帧场景)。 // ② 用圆角区域裁剪后只 BitBlt 面板本体(圆角之外仍是本帧场景)。
if (!m_fastFrame) // ⚠️⚠️ **投影不能每帧重画**(用户 2026-11-02 报告“移动鼠标/拖动光标卡顿”):
// 它是一块面积与面板同大的**半透明抗锯齿圆角矩形**(中心轴面板高近 950px、
// 宽 400px),GDI+ 软件渲染这一块实测要 **20ms 以上** —— 而缓存命中的
// 贴图本身只要零点几毫秒。交互期间(拖动 / 鼠标刚移动)直接跳过投影:
// 面板圆角之外保留上一帧的像素(此时那块区域本来也是静止的场景),
// 鼠标停下后键恢复、面板重画一次即补上投影。
if (!m_fastFrame && !interactiveFrame)
{ {
UiFillRoundRect(g, gd::RectF(panel.X + 1.0f * m_scale, UiFillRoundRect(g, gd::RectF(panel.X + 1.0f * m_scale,
panel.Y + 2.0f * m_scale, panel.Width, panel.Y + 2.0f * m_scale, panel.Width,
@@ -4852,7 +4938,7 @@ namespace jdi
// 「均值滤波」面板:逐信号的**信号色叠底标题栏**(左侧展开三角 + 信号名 + // 「均值滤波」面板:逐信号的**信号色叠底标题栏**(左侧展开三角 + 信号名 +
// 右端复选框)。复选框勾选后才出现三角、才能展开下面的两行参数滑块 // 右端复选框)。复选框勾选后才出现三角、才能展开下面的两行参数滑块
// (「均值点数」/「迭代次数」);内容超高时可滚动(滚动条在内容区右侧)。 // (「均值点数」/「迭代次数」);内容超高时可滚动(滚动条在内容区右侧)。
if (meanFilter) if (listStyle)
{ {
const int meanSave = g.Save(); const int meanSave = g.Save();
// 内容必须裁到内容区:滚动时行会越出可视范围。 // 内容必须裁到内容区:滚动时行会越出可视范围。
@@ -4938,6 +5024,10 @@ namespace jdi
{ {
extendRow(layout.rowIterResets[rowIndex]); extendRow(layout.rowIterResets[rowIndex]);
} }
if (rowIndex < layout.rowStats.size())
{
extendRow(layout.rowStats[rowIndex]);
}
if (rowBottom <= layout.content.Y || if (rowBottom <= layout.content.Y ||
rowTop >= layout.content.Y + layout.content.Height) rowTop >= layout.content.Y + layout.content.Height)
{ {
@@ -4945,7 +5035,10 @@ namespace jdi
} }
const std::uint32_t signalColor = StyleColor(*resolved.style); const std::uint32_t signalColor = StyleColor(*resolved.style);
const UiInk ink = UiContrastInk(signalColor); const UiInk ink = UiContrastInk(signalColor);
const Lane::MeanFilterItem* item = tabLane.FindMeanFilterItem(ref); // 「均值滤波」看 `meanFilterItems`、「中心轴边沿计数」看 `edgeCenterItems`
// (两者都是 `Lane::MeanFilterItem`,字段完全一致)。
const Lane::MeanFilterItem* item = meanFilter ? tabLane.FindMeanFilterItem(ref)
: tabLane.FindEdgeCenterItem(ref);
const bool checked = (item != nullptr); const bool checked = (item != nullptr);
// 内容区已由 `g.SetClip(layout.content)` 裁切(行可以“半截”显示,滚动更顺滑); // 内容区已由 `g.SetClip(layout.content)` 裁切(行可以“半截”显示,滚动更顺滑);
@@ -4987,6 +5080,19 @@ namespace jdi
continue; continue;
} }
// 「中心轴边沿计数」:参数行最上面是**同一行**的「上升沿 / 下降沿」统计。
if (centerAxis && rowIndex < layout.rowStats.size() &&
layout.rowStats[rowIndex].Width > 0.0f)
{
std::size_t rising = 0;
std::size_t falling = 0;
bool complete = true;
EdgeCenterCountsFor(*resolved.axis, *resolved.data, *resolved.style, item->points,
item->iterations, EdgeRangeActive(), EdgeRangeMin(),
EdgeRangeMax(), rising, falling, complete);
paintCounts(layout.rowStats[rowIndex], rising, falling, complete);
}
// 两行参数滑块(标签 / 轨道 / 数值 / 复位按钮)。 // 两行参数滑块(标签 / 轨道 / 数值 / 复位按钮)。
const auto paintMeanSlider = [&](const gd::RectF& track, const auto paintMeanSlider = [&](const gd::RectF& track,
const gd::RectF& valueRect, const gd::RectF& valueRect,
@@ -5031,11 +5137,15 @@ namespace jdi
hot ? m_theme->accent : m_theme->textMuted, m_scale); hot ? m_theme->accent : m_theme->textMuted, m_scale);
}; };
const int pointsMax = std::max(kMeanFilterPointsMin, // ⚠️ 「均值点数」的最小值:**均值滤波 = 1**(用户 2026-10-30 要求不能为 0,
// 0 与不做平滑完全等价,看起来像功能坏了);**中心轴边沿计数 = 0**
// (0 = 不平滑,直接对原始采样值统计边沿 —— 这是有意义的选项,保持旧语义)。
const int pointsFloor = centerAxis ? 0 : kMeanFilterPointsMin;
const int pointsMax = std::max(pointsFloor,
std::min(kMeanFilterPointsMaxLimit, std::min(kMeanFilterPointsMaxLimit,
item->maxPoints)); item->maxPoints));
const int points = const int points =
std::max(kMeanFilterPointsMin, std::min(pointsMax, item->points)); std::max(pointsFloor, std::min(pointsMax, item->points));
const int iterationsMax = std::max(kMeanFilterIterationsMin, const int iterationsMax = std::max(kMeanFilterIterationsMin,
std::min(kMeanFilterIterationsMaxLimit, std::min(kMeanFilterIterationsMaxLimit,
item->maxIterations)); item->maxIterations));
@@ -5043,10 +5153,11 @@ namespace jdi
kMeanFilterIterationsMin, std::min(iterationsMax, item->iterations)); kMeanFilterIterationsMin, std::min(iterationsMax, item->iterations));
const bool pointsActive = (m_dragMode == PlotDragMode::MEAN_POINTS) && const bool pointsActive = (m_dragMode == PlotDragMode::MEAN_POINTS) &&
(m_meanDragLane == static_cast<int>(index)) && (m_meanDragLane == static_cast<int>(index)) &&
(m_meanDragRef == ref); (m_meanDragRef == ref) && (m_meanDragCenter == centerAxis);
const bool iterationsActive = const bool iterationsActive =
(m_dragMode == PlotDragMode::MEAN_ITERATIONS) && (m_dragMode == PlotDragMode::MEAN_ITERATIONS) &&
(m_meanDragLane == static_cast<int>(index)) && (m_meanDragRef == ref); (m_meanDragLane == static_cast<int>(index)) && (m_meanDragRef == ref) &&
(m_meanDragCenter == centerAxis);
if (rowIndex < layout.rowLevelTracks.size()) if (rowIndex < layout.rowLevelTracks.size())
{ {
paintMeanSlider(layout.rowLevelTracks[rowIndex], paintMeanSlider(layout.rowLevelTracks[rowIndex],
@@ -5057,7 +5168,7 @@ namespace jdi
? layout.rowLevelResets[rowIndex] ? layout.rowLevelResets[rowIndex]
: gd::RectF(), : gd::RectF(),
kMeanFilterPointsSliderLabel, points, kMeanFilterPointsSliderLabel, points,
kMeanFilterPointsMin, pointsMax, pointsActive); pointsFloor, pointsMax, pointsActive);
} }
if (rowIndex < layout.rowIterTracks.size()) if (rowIndex < layout.rowIterTracks.size())
{ {
@@ -5434,7 +5545,7 @@ namespace jdi
{ {
continue; continue;
} }
if (itemized || freeMarker || meanFilter) if (itemized || freeMarker || listStyle)
{ {
// 逐条目绘制已在上面完成(自由标记同样是逐条目排布, // 逐条目绘制已在上面完成(自由标记同样是逐条目排布,
// 均值滤波走自己的可滚动分支)。 // 均值滤波走自己的可滚动分支)。
+42 -32
View File
@@ -28,8 +28,10 @@ namespace jdi
const bool centerAxis = (kind == EdgePanelKind::CENTER); const bool centerAxis = (kind == EdgePanelKind::CENTER);
const bool meanFilter = (kind == EdgePanelKind::MEAN); const bool meanFilter = (kind == EdgePanelKind::MEAN);
const bool weakPeak = (kind == EdgePanelKind::PEAK); const bool weakPeak = (kind == EdgePanelKind::PEAK);
// 「均值滤波」「中心轴边沿计数」「弱峰调整」都是逐条目面板(名称 + 复选框)。 // 「弱峰调整」仍是旧式逐条目面板(名称 + 复选框 + 两行滑块)。
const bool itemized = (centerAxis || meanFilter || weakPeak); // ⚠️ 「均值滤波」与「中心轴边沿计数」已改为**可滚动的分组列表**(信号色叠底条目栏),
// 它们的宽度由下面的 `groupWidth` / 滑块行分支确定。
const bool itemized = weakPeak;
// 标题行 = “GX” 徽标 + 标题(“边沿计数” / “中心轴边沿计数” / “均值滤波” / “弱峰调整” // 标题行 = “GX” 徽标 + 标题(“边沿计数” / “中心轴边沿计数” / “均值滤波” / “弱峰调整”
// + 光标区间尾缀)。 // + 光标区间尾缀)。
@@ -69,10 +71,10 @@ namespace jdi
width = std::max(width, kEdgePanelPad * 2.0f * m_scale + nameWidth + width = std::max(width, kEdgePanelPad * 2.0f * m_scale + nameWidth +
kEdgePanelMeanCheckSize * m_scale + 6.0f * m_scale); kEdgePanelMeanCheckSize * m_scale + 6.0f * m_scale);
} }
if (meanFilter) if (meanFilter || centerAxis)
{ {
// 「均值滤波」的条目 = **信号色叠底标题栏**(三角 + 名称 + 复选框) // 「均值滤波 / 中心轴边沿计数」的条目 = **信号色叠底条目栏**(三角 + 名称 + 复选框)
// 与两行滑块(标签 + 轨道 + 数值 + 复位按钮),右侧还要给滚动条留位。 // 与参数行(标签 + 轨道 + 数值 + 复位按钮),右侧还要给滚动条留位。
const float groupWidth = kEdgePanelPad * 2.0f * m_scale + const float groupWidth = kEdgePanelPad * 2.0f * m_scale +
kEdgePanelDisclosureWidth * m_scale + 6.0f * m_scale + kEdgePanelDisclosureWidth * m_scale + 6.0f * m_scale +
nameWidth + 6.0f * m_scale + nameWidth + 6.0f * m_scale +
@@ -124,21 +126,7 @@ namespace jdi
std::max(nameColumnWidth, countsWidth)); std::max(nameColumnWidth, countsWidth));
} }
if (centerAxis || meanFilter) if (meanFilter || centerAxis)
{
// 参数行 = 两个「说明文字 + 数值框」,必须保证文字不被数值框压住。
// (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)
{ {
// 两行滑块 = 「标签列 + 轨道(最小宽度)+ 数值列 + 复位按钮」(两行共用同一列宽), // 两行滑块 = 「标签列 + 轨道(最小宽度)+ 数值列 + 复位按钮」(两行共用同一列宽),
// 右侧再给滚动条留位(上百个信号时内容必然超高)。 // 右侧再给滚动条留位(上百个信号时内容必然超高)。
@@ -263,7 +251,11 @@ namespace jdi
// 「均值滤波」与「弱峰调整」共用同一套「逐条目 + 底部按钮」排布。 // 「均值滤波」与「弱峰调整」共用同一套「逐条目 + 底部按钮」排布。
// ⚠️ 「均值滤波」从 2026-10-30 起改为**可滚动的分组列表**(信号色叠底标题栏 + // ⚠️ 「均值滤波」从 2026-10-30 起改为**可滚动的分组列表**(信号色叠底标题栏 +
// 展开后的两行滑块),与「自由标记」同一套浏览方式 ⇒ 不再走这里的 `itemized` 分支。 // 展开后的两行滑块),与「自由标记」同一套浏览方式 ⇒ 不再走这里的 `itemized` 分支。
const bool itemized = (centerAxis || weakPeak); // ⚠️ 「中心轴边沿计数」从 2026-11-02 起同样改为**可滚动的分组列表**(用户明确要求
// “改造为与均值滤波相同的风格”)⇒ 也不走 `itemized`。它没有「构建为新信号」按钮,
// 但内容多一行「上升沿 / 下降沿」统计。
const bool listStyle = (meanFilter || centerAxis);
const bool itemized = weakPeak;
const bool hasButton = (meanFilter || weakPeak); const bool hasButton = (meanFilter || weakPeak);
const bool enabled = meanFilter ? lane.meanFilter const bool enabled = meanFilter ? lane.meanFilter
: (weakPeak ? lane.weakPeak : (weakPeak ? lane.weakPeak
@@ -322,11 +314,12 @@ namespace jdi
float itemsHeight = 0.0f; float itemsHeight = 0.0f;
float meanContentHeight = 0.0f; float meanContentHeight = 0.0f;
float height = 0.0f; float height = 0.0f;
if (meanFilter) if (listStyle)
{ {
// 「均值滤波」:内容区 = 逐信号的**信号色叠底标题栏**(内容总是显示, // 「均值滤波 / 中心轴边沿计数」:内容区 = 逐信号的**信号色叠底条目栏**
// 用于勾选/取消预览)+(勾选且未收起时)两行参数滑块。 //(内容总是显示,用于勾选/取消)+(勾选且未收起时)参数行。
// 内容超高时出现滚动条;底部「构建为新信号」按钮固定不滚动。 // 「中心轴」的参数行 = 一行「上升沿 / 下降沿」统计 + 两行滑块;
// 「均值滤波」= 两行滑块。内容超高时出现滚动条;底部「构建为新信号」按钮固定不滚动。
const float groupRow = kEdgePanelMeanRowHeight * m_scale; const float groupRow = kEdgePanelMeanRowHeight * m_scale;
const float sliderRow = weakPeakSliderRow; const float sliderRow = weakPeakSliderRow;
float content = 0.0f; float content = 0.0f;
@@ -338,10 +331,15 @@ namespace jdi
continue; continue;
} }
content += groupRow; content += groupRow;
const Lane::MeanFilterItem* item = lane.FindMeanFilterItem(ref); const Lane::MeanFilterItem* item = meanFilter ? lane.FindMeanFilterItem(ref)
: lane.FindEdgeCenterItem(ref);
if (item != nullptr && !item->collapsed) if (item != nullptr && !item->collapsed)
{ {
content += sliderRow * 2.0f; content += sliderRow * 2.0f;
if (centerAxis)
{
content += groupRow; // 统计行(上升沿 / 下降沿)
}
} }
} }
meanContentHeight = content; meanContentHeight = content;
@@ -562,12 +560,16 @@ namespace jdi
{ {
return layout; return layout;
} }
if (meanFilter) if (listStyle)
{ {
// 「均值滤波」:可滚动内容区(逐信号的信号色叠底标题栏 + 展开后的两行滑块)+ 底部按钮。 // 「均值滤波 / 中心轴边沿计数」:可滚动内容区
//(逐信号的信号色叠底条目栏 + 展开后的统计行与两行滑块)+(均值滤波)底部按钮。
const float buttonRowHeight = kEdgePanelButtonHeight * m_scale; const float buttonRowHeight = kEdgePanelButtonHeight * m_scale;
layout.button = gd::RectF(left + pad, top + height - pad - buttonRowHeight, if (hasButton)
std::max(0.0f, width - pad * 2.0f), buttonRowHeight); {
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 groupRow = kEdgePanelMeanRowHeight * m_scale;
const float sliderRow = kWeakPeakSliderRowHeight * m_scale; const float sliderRow = kWeakPeakSliderRowHeight * m_scale;
@@ -576,7 +578,8 @@ namespace jdi
std::max(0.0f, height - titleHeight - pad * 3.0f - buttonRowHeight); std::max(0.0f, height - titleHeight - pad * 3.0f - buttonRowHeight);
const bool scrollable = meanContentHeight > viewHeight + 0.5f; const bool scrollable = meanContentHeight > viewHeight + 0.5f;
const float maxScroll = std::max(0.0f, meanContentHeight - viewHeight); 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 : lane.edgeCenterScroll;
const float scroll = std::max(0.0f, std::min(storedScroll, maxScroll));
const float innerWidth = const float innerWidth =
std::max(0.0f, width - pad * 2.0f - (scrollable ? (scrollW + 4.0f * m_scale) : 0.0f)); 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); layout.content = gd::RectF(left + pad, top + titleHeight + pad, innerWidth, viewHeight);
@@ -621,7 +624,8 @@ namespace jdi
{ {
continue; continue;
} }
const Lane::MeanFilterItem* item = lane.FindMeanFilterItem(ref); const Lane::MeanFilterItem* item = meanFilter ? lane.FindMeanFilterItem(ref)
: lane.FindEdgeCenterItem(ref);
const bool checked = (item != nullptr); const bool checked = (item != nullptr);
layout.rowRefs.push_back(ref); layout.rowRefs.push_back(ref);
// 组头(信号色叠底):整行宽度,左侧三角(仅勾选时)+ 名称,右端复选框。 // 组头(信号色叠底):整行宽度,左侧三角(仅勾选时)+ 名称,右端复选框。
@@ -653,6 +657,12 @@ namespace jdi
layout.rowIterResets.push_back(gd::RectF()); layout.rowIterResets.push_back(gd::RectF());
continue; 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, const gd::RectF pointsTrack(trackLeft, rowY + (sliderRow - trackHeight) * 0.5f,
trackWidth, trackHeight); trackWidth, trackHeight);
layout.rowLevelTracks.push_back(pointsTrack); layout.rowLevelTracks.push_back(pointsTrack);
@@ -382,14 +382,6 @@ namespace jdi
m_rowTop[index + 1] = m_rowTop[index] + m_rows[index].height; 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; float totalShift = 0.0f;
for (const GroupAnim& anim : m_groupAnims) for (const GroupAnim& anim : m_groupAnims)
{ {
+112
View File
@@ -8941,3 +8941,115 @@ rendercheck 8 个场景哈希与基线完全一致;make release / make debug
白改了一轮断言。 白改了一轮断言。
* ⚠️ PowerShell 里用 `'...'` 拼接含 `%d` / 引号 / 长中文的补丁串极易被截断(出现 `>>` 续行提示后 * ⚠️ PowerShell 里用 `'...'` 拼接含 `%d` / 引号 / 长中文的补丁串极易被截断(出现 `>>` 续行提示后
只能 Ctrl+C)。**补丁一律写成 `.git\xxx.ps1`(纯 ASCII)+ `%TEMP%\xxx.txt` 数据文件**。 只能 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 59/59
A1 空闲 0.7ms · A2 平移 2.5ms · A3 双光标 2.4ms · A4 自由标记光标 5.2ms
A5 中心轴拖动 6.6ms(修前 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 点 / 复位 → 5 点且上限 50 / 滚轮滚动 60px
D 唯一标签页关闭 → 「视图 1」+ 全折起 + 无选中;手动新建同样
.preflight/panelcheck.exe 71/71 markercheck.exe 13/13 ringcheck.exe 6/6
make release / make debug 零 warning
```
### 6. 踩坑
* ⚠️⚠️ **`PaintEdgeCursor` 的"笨重"必须先分段测量**:本轮一开始把 30ms 归因于"统计扫描"
和"统计数字进缓存键",改了预算与键都不见效;直到做了「面板开/关」「统计开/关」的
A/B 才定位到**面板高度超界 ⇒ 像素缓存从未命中**。**判据要用"缓存是否真的被用上"
(`m_panelSlots[0].key != 0` + `PanelRectInsideLayer`),而不是"看起来像重画"**。
* ⚠️ 探针里模拟点击面板前**必须关掉其它实验性功能**:它们默认位置同样在绘图区右上角,
`TopPanelAtPoint` 只返回最上层那一个 ⇒ 命中全被另一个面板抢走(表现为"点了没反应")。
* ⚠️ 组名行的点击热区:`X = 行矩形 40%` 处是名称(BODY),`x = 40px` 附近是**复选框**
(`RowPart::CHECK`)—— 想测折叠必须点名称区。
* ⚠️ 「行集合签名是否稳定」不能作为断言:动画**结束那一帧**签名必然变化一次(动画标记位
从 5 → 0),这是正确的。要断言的是"动画结束后 `m_rowsSignature == RowsSignature()`"。
+9
View File
@@ -1067,6 +1067,15 @@ namespace jdi
*/ */
float meanFilterScroll = 0.0f; float meanFilterScroll = 0.0f;
/**
* @brief 「中心轴边沿计数」面板内容的滚动偏移(客户区像素,0 = 顶部)。
*
* 与 `meanFilterScroll` 完全同一套语义:面板自 2026-11-02 起改为**可滚动的分组列表**
*(信号色叠底条目栏 + 右侧复选框 + 展开后的统计行与两行参数滑块),
* 内容超出可视高度时右侧出现滚动条(否则上百个信号会把面板顶出绘图区)。
*/
float edgeCenterScroll = 0.0f;
/** /**
* @brief 「自由标记」面板里被**收起**的信号分组(点击分组栏切换)。 * @brief 「自由标记」面板里被**收起**的信号分组(点击分组栏切换)。
* *
+4
View File
@@ -142,6 +142,8 @@ namespace jdi
std::vector<SignalRef> freeMarkerCollapsedGroups; std::vector<SignalRef> freeMarkerCollapsedGroups;
/** @brief 「均值滤波」面板内容的滚动偏移(详见 `Lane::meanFilterScroll`)。 */ /** @brief 「均值滤波」面板内容的滚动偏移(详见 `Lane::meanFilterScroll`)。 */
float meanFilterScroll = 0.0f; float meanFilterScroll = 0.0f;
/** @brief 「中心轴边沿计数」面板内容的滚动偏移(详见 `Lane::edgeCenterScroll`)。 */
float edgeCenterScroll = 0.0f;
}; };
// 先记下每个信号原来所在网格的网格自身状态: // 先记下每个信号原来所在网格的网格自身状态:
@@ -173,6 +175,7 @@ namespace jdi
carry.edgeCenter = lane.edgeCenter; carry.edgeCenter = lane.edgeCenter;
carry.edgeCenterItems = lane.edgeCenterItems; carry.edgeCenterItems = lane.edgeCenterItems;
carry.edgeCenterPanel = lane.edgeCenterPanel; carry.edgeCenterPanel = lane.edgeCenterPanel;
carry.edgeCenterScroll = lane.edgeCenterScroll;
carry.meanFilter = lane.meanFilter; carry.meanFilter = lane.meanFilter;
carry.meanFilterItems = lane.meanFilterItems; carry.meanFilterItems = lane.meanFilterItems;
carry.meanFilterPanel = lane.meanFilterPanel; carry.meanFilterPanel = lane.meanFilterPanel;
@@ -233,6 +236,7 @@ namespace jdi
lane.edgeCenterPanel.collapsed = carry.edgeCenterPanel.collapsed; lane.edgeCenterPanel.collapsed = carry.edgeCenterPanel.collapsed;
lane.edgeCenterPanel.x = carry.edgeCenterPanel.x; lane.edgeCenterPanel.x = carry.edgeCenterPanel.x;
lane.edgeCenterPanel.y = carry.edgeCenterPanel.y; lane.edgeCenterPanel.y = carry.edgeCenterPanel.y;
lane.edgeCenterScroll = carry.edgeCenterScroll;
lane.meanFilter = carry.meanFilter; lane.meanFilter = carry.meanFilter;
lane.meanFilterItems = carry.meanFilterItems; lane.meanFilterItems = carry.meanFilterItems;
lane.meanFilterPanel.open = carry.meanFilterPanel.open; lane.meanFilterPanel.open = carry.meanFilterPanel.open;