From ea704b03ea2d99f963291cca6952c3f220884cc5 Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Mon, 2 Nov 2026 16:15:40 +0800 Subject: [PATCH] =?UTF-8?q?fix(plotview):=20=E8=BE=B9=E6=B2=BF=E8=AE=A1?= =?UTF-8?q?=E6=95=B0=E8=A1=A8=E6=A0=BC=E5=BD=92=E4=BD=8D=E3=80=81=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1=E9=9D=A2=E6=9D=BF=E6=82=AC=E5=81=9C=E6=97=B6=E6=8C=81?= =?UTF-8?q?=E7=BB=AD=E6=9B=B4=E6=96=B0=E3=80=81=E5=8A=A0=E8=BD=BD=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E8=A2=AB=E7=BB=9F=E8=AE=A1=E9=98=BB=E5=A1=9E=E3=80=81?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E6=8F=90=E9=80=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) 表格改造归位(上一轮误做在中心轴上) - 「边沿计数」改为表格:表头(信号名/上升沿计数/下降沿计数)、每信号一行、 行底色取信号配色、三列对齐、内容可滚动(Lane::edgeCountScroll)。 - 「中心轴边沿计数」恢复为可滚动分组列表(条目栏 + 复选框 + 展开后统计行 + 两行滑块), Lane::EnsureEdgeCenterItem 恢复默认展开。 - EdgePanelWidth / EdgePanelLayoutFor / PaintEdgeCursor 统一 listStyle / countTable 判定; EdgeCenterTableHeader 更名为 EdgeCountTableHeader。 2) 鼠标持续移动时统计面板不更新 - 根因:交互态(拖拽或 140ms 内移动)把统计数字整段从面板缓存键中跳过, 只要鼠标不停移动就永远命中旧缓存。 - 修法:扫描推进/完成时自增 m_edgeScanRevision,Paint 开头转为 m_panelStatsVersion (非交互帧立即同步,交互帧按 kEdgeScanStatsRefreshFrames=4 节流), PanelSlotKey 对 COUNT/CENTER 仅混入该版本号(O(1),不再逐信号查询)。 3) 统计进行中无法加载文件 - 新增 PlotView::SetLoadingBusy:加载期间统计时间预算归零、跳过浮动面板绘制、 不再自请求扫描帧;Application::LoadFileAsync 进出各设置一次。实测加载 22~31ms。 4) 统计提速 - 扫描预算由固定采样对改为 QPC 时间片(非交互 6ms / 交互 1.5ms,每块检查超时), 采样对配额改为按未完成状态数公平分摊并把上限提到 8M(交互 2M)。 - 真实 MF4-Record.MF4(283 信号)全部统计状态在 1 帧内完成;悬停+移动帧中位 2~3ms。 验证:离屏探针 p20/p20c/p20d/p21/p22/p23/p25 全绿(新增 p25 12 项); make release / make debug 零 warning;Release 808,960 B。共享规则已同步到 Documents/agent_mem.md。 --- .../Application/Src/ApplicationLoading.cpp | 5 + Components/PlotView/Inc/PlotView.hpp | 45 ++ Components/PlotView/Inc/PlotViewInternal.hpp | 37 +- Components/PlotView/Src/PlotViewFeatures.cpp | 34 ++ .../PlotView/Src/PlotViewInteraction.cpp | 2 +- Components/PlotView/Src/PlotViewPaint.cpp | 440 ++++-------------- Components/PlotView/Src/PlotViewPanels.cpp | 317 +++++-------- Documents/agent_mem.md | 93 ++++ Utilities/Src/DataModel.cpp | 4 - 9 files changed, 424 insertions(+), 553 deletions(-) diff --git a/Components/Application/Src/ApplicationLoading.cpp b/Components/Application/Src/ApplicationLoading.cpp index 4025c6e..5b6da30 100644 --- a/Components/Application/Src/ApplicationLoading.cpp +++ b/Components/Application/Src/ApplicationLoading.cpp @@ -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)) { diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 0c5dbd2..8d256ea 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -1280,6 +1280,17 @@ namespace jdi */ bool PanelRefreshPending() const { return m_panelKeysFrozen; } + /** + * @brief 告知绘图区「宿主正在加载文件」。 + * + * 加载期间主线程会不断 `UpdateWindow` 触发整帧绘制,而面板绘制含统计推进与上百行 + * GDI+ 原语 ⇒ 加载进度被拖得很慢(用户报告"统计没算完就无法开始加载")。 + * 加载遮罩会盖住整个界面,因此置真时直接跳过面板绘制与统计推进。 + * + * @param [IN] busy true = 正在加载。 + */ + void SetLoadingBusy(bool busy) { m_loadingBusy = busy; } + /** * @brief 取消网格滚动动画(用户自己滚动网格时调用,避免动画与手动滚动抢滚动位置)。 @@ -3138,6 +3149,40 @@ namespace jdi /** 诊断计数:中心轴本地曲线计算失败次数(内存不足等)。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 累积状态上限(随可见信号总数缩放)。 * diff --git a/Components/PlotView/Inc/PlotViewInternal.hpp b/Components/PlotView/Inc/PlotViewInternal.hpp index 30086a9..0ccb809 100644 --- a/Components/PlotView/Inc/PlotViewInternal.hpp +++ b/Components/PlotView/Inc/PlotViewInternal.hpp @@ -632,19 +632,36 @@ namespace jdi * 1200000 对 × 11 次浮点运算 ≈ 3 位千万级运算/帧。 * 现在交互期间改用 `kEdgeScanPairsPerFrameInteractive`(见 `Paint` 开头)。 */ - inline const std::size_t kEdgeScanPairsPerFrame = 400000; + inline const std::size_t kEdgeScanPairsPerFrame = 8000000; /** - * @brief **交互帧**(正在拖动 / 鼠标刚移动过)允许的边沿统计预算。 + * @brief **交互帧**(正在拖拽 / 鼠标刚移动过)允许的边沿统计**采样对**配额。 * - * 交互时首要目标是跟手:把预算压到非交互帧的 1/5(实测单帧统计开销 ≈6ms 以内), - * 鼠标停下 `kEdgeScanInteractiveIdleMs` 之后自动恢复高预算、快速把统计补齐。 - * ⚠️ 不能降到 0 —— 那样“按住横向光标不动”时统计会停在“统计中…”。 + * 真正限制开销的是下面的「时间预算」(`m_edgeScanDeadline`):同样的采样对数在 + * 普通计数与中心轴(每个对还要算窗口均值)之间能差一个量级,因此**按时间切**: + * 采样对配额只负责“公平分摊”(不让第一个状态吃光全帧)。 */ - inline const std::size_t kEdgeScanPairsPerFrameInteractive = 80000; + 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 每个累积状态每帧的扫描配额下限(防止多个状态时前面的把预算吃光)。 * @@ -701,10 +718,10 @@ namespace jdi } /** - * @brief 表格单元格里的**纯数字**(「中心轴边沿计数」表格用)。 + * @brief 表格单元格里的**纯数字**(「边沿计数」表格用)。 * * 表头已经写了「上升沿计数 / 下降沿计数」,单元格里再写「上升沿 N」就重复了; - * 未算完时在数字后追加 `…`(与旧的两列排版同一个语义)。 + * 未算完时在数字后追加 `…`(与列表式排版的语义一致)。 */ inline std::wstring EdgeCountCellText(std::size_t value, bool complete) { @@ -716,8 +733,8 @@ namespace jdi return text; } - /** @brief 「中心轴边沿计数」表格的表头文本(信号名 / 上升沿计数 / 下降沿计数)。 */ - inline const wchar_t* EdgeCenterTableHeader(int column) + /** @brief 「边沿计数」表格的表头文本(信号名 / 上升沿计数 / 下降沿计数)。 */ + inline const wchar_t* EdgeCountTableHeader(int column) { switch (column) { diff --git a/Components/PlotView/Src/PlotViewFeatures.cpp b/Components/PlotView/Src/PlotViewFeatures.cpp index 2a481dd..c019980 100644 --- a/Components/PlotView/Src/PlotViewFeatures.cpp +++ b/Components/PlotView/Src/PlotViewFeatures.cpp @@ -2648,6 +2648,10 @@ namespace jdi // 本函数在 `EdgeScanFor` 里按“未完成的状态”逐个调用,288 条信号时单帧会 Post 288 次! // 大量 `WM_MSG`+重绘会把优先级更低的 `WM_TIMER` 饿死(分组折起动画因此永远结束不了), // 同时把 CPU 占满、拖慢所有交互。用一个“本帧已请求”标志收敛成一次。 + if (m_loadingBusy) + { + return; // 加载中:不推进、不自请求(否则加载进度被统计重绘拖慢) + } if (m_edgeScanRequestFrame == m_edgeScanFrame) { return; @@ -2705,6 +2709,14 @@ namespace jdi falling = 0; complete = false; + // ⚠️ **宿主正在加载文件时不推进**:加载线程的主循环不断 `UpdateWindow` 触发整帧绘制, + // 若这里继续扫,加载会被统计抢走 CPU(用户报告“统计没算完就无法开始加载”)。 + // 加载完成后数据层结构变化会整体作废累积状态,本来就要重算。 + if (m_loadingBusy) + { + return; + } + const std::size_t count = signal.values.size(); if (count < 2) { @@ -2800,6 +2812,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) { @@ -2814,6 +2827,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; } // ---- 中心轴:**就地**计算所需的那一小段均值滤波曲线 --------------------- @@ -2989,6 +3003,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; @@ -3057,6 +3082,10 @@ namespace jdi { ++block; } + if (timeUp()) + { + break; // 本帧的时间预算用完:剩下的帧接着扫 + } } state->scannedPair = pair; state->complete = (pair >= count); @@ -3072,6 +3101,11 @@ namespace jdi } state->pumpedPairs += scanned; m_edgeScanBudget -= std::min(m_edgeScanBudget, scanned); + if (scanned > 0) + { + // 数字(或“是否扫完”)变了 ⇒ 面板缓存键要跟着变,否则会一直贴旧像素。 + ++m_edgeScanRevision; + } } } diff --git a/Components/PlotView/Src/PlotViewInteraction.cpp b/Components/PlotView/Src/PlotViewInteraction.cpp index 36701db..2d8fd76 100644 --- a/Components/PlotView/Src/PlotViewInteraction.cpp +++ b/Components/PlotView/Src/PlotViewInteraction.cpp @@ -2359,7 +2359,7 @@ namespace jdi // 「均值滤波」「中心轴边沿计数」「弱峰调整」自 2026-11-02 起共用同一套 //**可滚动列表**交互(信号色叠底条目栏 + 右侧复选框 + 展开后的两行参数滑块)。 const bool listMode = (meanMode || centerMode || boostMode); - // 「边沿计数」仍是最简单的两行列表,只需要滚动条拖动。 + // 「边沿计数」= **表格**(表头 + 每信号一行),只有滚动条可拖。 if (kind == EdgePanelKind::COUNT) { if (layout.scrollThumb.Width > 0.0f && diff --git a/Components/PlotView/Src/PlotViewPaint.cpp b/Components/PlotView/Src/PlotViewPaint.cpp index b276d61..4d8f903 100644 --- a/Components/PlotView/Src/PlotViewPaint.cpp +++ b/Components/PlotView/Src/PlotViewPaint.cpp @@ -835,18 +835,40 @@ namespace jdi // 就会在同一系统 tick 内连画多帧而**完全不推进**统计 —— 用户报告 // “按住横向光标不动时统计卡住不更新”“回车确认参数后不移动鼠标就停在部分值”。 ++m_edgeScanFrame; - // ⚠️⚠️ **交互帧必须用小预算**(用户 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_edgeScanRevision`): + // * 非交互帧:**立刻**同步(统计一推进就重画面板,用户看到数字实时变化); + // * 交互帧:按 `kEdgeScanStatsRefreshFrames` 节流 —— 否则鼠标一直移动时 + // 面板每帧重画上百行(正是“悬停在信号上持续移动、面板一直不更新”的反面: + // 以前是冻结永不更新,现在是有节制地刷新)。 + if (m_edgeScanRevision != m_panelStatsSeen && + (!InputIsInteractive() || + (m_edgeScanFrame % kEdgeScanStatsRefreshFrames) == 0u)) + { + m_panelStatsVersion = m_edgeScanRevision; + m_panelStatsSeen = m_edgeScanRevision; + } + // ⚠️⚠️ **交互帧用小预算 + 时间上限**:288 个信号合并到同一网格、开启中心轴边沿计数时, + // 拖动横向光标每帧要 30ms(实测),界面完全跟不上手。现在: + // * 采样对配额只做“公平分摊”(不让第一个状态吃光全帧); + // * 真正的总开销由 `m_edgeScanDeadline`(时间)限制 —— 普通计数因此能一次推进得很快, + // 中心轴也不会卡住一帧; + // * 加载中(`m_loadingBusy`)完全不推进,避免把文件加载拖慢。 + const bool interactive = InputIsInteractive(); + m_edgeScanBudget = m_loadingBusy ? 0 + : (interactive ? kEdgeScanPairsPerFrameInteractive + : kEdgeScanPairsPerFrame); + { + LARGE_INTEGER frequency = {}; + LARGE_INTEGER now = {}; + QueryPerformanceFrequency(&frequency); + QueryPerformanceCounter(&now); + const double budgetMs = m_loadingBusy ? 0.0 + : (interactive ? kEdgeScanTimeBudgetInteractiveMs + : kEdgeScanTimeBudgetMs); + m_edgeScanDeadline = + now.QuadPart + static_cast(static_cast(frequency.QuadPart) * + budgetMs / 1000.0); + } // 双缓冲:清掉上一帧钉住的曲线条目(本帧会按需重新钉住),并清理已被更新条目覆盖 / // 超出内存预算的旧条目。此刻没有任何条目被钉住,回收是安全的。 m_curvePins.clear(); @@ -3835,71 +3857,25 @@ namespace jdi mix((peakItem != nullptr && peakItem->collapsed) ? 1ull : 0ull); } - // ⚠️⚠️ **边沿统计的数字本身就是面板内容,必须进缓存键**(用户报告的缺陷根因): - // 面板像素图层按本键缓存,而界面上的「上升沿 / 下降沿」是**分帧推进**出来的中间结果 —— - // 若这些数字不进键,则「统计推进 → 数字变化」不会让键变化 ⇒ 面板一直贴旧像素 - // ⇒ 用户不动鼠标时统计永远停在“统计中…”或某个中间值,**只有移动鼠标**(改变别的键) - // 才会显示出早已算完的正确结果。 - // ⇒ 把该面板当前要显示的 (上升沿, 下降沿, 是否扫完) 一并 mix 进去:推进会让键变化 ⇒ - // 面板重绘 ⇒ 显示随进度更新,直到扫完(扫完后数字不再变,重新命中缓存)。 + // ⚠️⚠️ **边沿统计的数字本身就是面板内容,数字变化必须能让面板重绘**: + // 界面上的「上升沿 / 下降沿」是**分帧推进**出来的中间结果,若数字变化不改变键, + // 面板就会一直贴旧像素 ⇒ 用户看到"永远统计中…"(只有移动鼠标/悬停面板才更新)。 // - // ⚠️⚠️ **但交互期间必须跳过这一整段**(用户 2026-11-02 报告“拖动横向光标卡顿”): - // * 计算这一段本身要为**每个信号**调一次 `EdgeCountsFor` / `EdgeCenterCountsFor` - // (含区间边界块扫描)—— 288 个信号时单帧 288 次查询; - // * 而且中间数字每帧都在变 ⇒ 键每帧都变 ⇒ 面板像素缓存**永远不命中**, - // 于是每帧都要把整块面板(288 行 × 十来个 GDI+ 原语)重画一遍 ⇒ 实测 30ms/帧。 - // 拖动/鼠标移动期间数字本来就在飞速重算,**显示中间值毫无意义**;此时让键保持稳定 - // (用一个固定常量占位)⇒ 面板直接贴上一帧像素 ⇒ 真正跟手。鼠标停下 - // `kEdgeScanInteractiveIdleMs` 之后恢复完整键 ⇒ 面板重画并显示最终数字。 - const bool interactiveKey = InputIsInteractive(); - if (kind == EdgePanelKind::COUNT && !interactiveKey) + // 但**不能**在这里为每个信号调一次 `EdgeCountsFor` / `EdgeCenterCountsFor`: + // * 288 个信号时单帧 288 次查询(每次还要线性查找累积状态); + // * 中间数字每帧都在变 ⇒ 键每帧都变 ⇒ 面板像素缓存**永远不命中**, + // 于是每帧都要把整块面板(上百行 × 十来个 GDI+ 原语)重画一遍 ⇒ 实测 30ms/帧。 + // + // ⇒ 改为混合一个**统计数据版本号**(O(1)、零查询): + // `EdgeScanFor` 每推进/完成一次就自增 `m_edgeScanRevision`;`Paint` 开头把它 + // 转成 `m_panelStatsVersion`(非交互帧立刻同步;交互帧节流,避免每帧重画)。 + // 于是"统计推进 ⇒ 版本号变化 ⇒ 面板重绘",而"没推进 ⇒ 键稳定 ⇒ 贴回缓存"。 + if (kind == EdgePanelKind::COUNT || kind == EdgePanelKind::CENTER) { - for (const SignalRef& ref : lane.signals) - { - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data == nullptr || resolved.style == nullptr || resolved.axis == 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); - mix(static_cast(rising)); - mix(static_cast(falling)); - mix(complete ? 1ull : 0ull); - } - } - else if (kind == EdgePanelKind::CENTER && !interactiveKey) - { - // 「中心轴边沿计数」是**表格**:每个“已勾选统计”的行都会显示两列数字, - // 因此 `rowRefs` 里凡是 `FindEdgeCenterItem != nullptr` 的都要把数字混进键 - //(⚠️ 表格没有 `rowStats` 了,不能再按它门控 —— 否则数字变化不会让键变化, - // 面板会永远停在“统计中…”)。 - for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) - { - const SignalRef& ref = layout.rowRefs[rowIndex]; - const ResolvedSignal resolved = ResolveSignal(ref); - const Lane::EdgeCenterItem* item = lane.FindEdgeCenterItem(ref); - if (resolved.data == nullptr || resolved.style == nullptr || resolved.axis == nullptr || - item == nullptr) - { - continue; - } - 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); - mix(static_cast(rising)); - mix(static_cast(falling)); - mix(complete ? 1ull : 0ull); - } + mix(m_panelStatsVersion); } + // 弱峰调整的滑块行矩形(每信号两行)。 for (const gd::RectF& rect : layout.rowLevelTracks) { @@ -4329,6 +4305,15 @@ namespace jdi { return; } + // ⚠️ **加载中跳过全部统计面板绘制**:加载线程主循环不断 `UpdateWindow` 触发整帧绘制, + // 而这里含统计推进与上百行 GDI+ 原语 ⇒ 加载进度被拖慢(用户报告“统计没算完就无法 + // 加载文件”)。加载遮罩会盖住整个界面,面板本来也看不见。 + if (m_loadingBusy) + { + m_panelSlots.clear(); + m_panelValid = false; + return; + } std::vector& tabLanes = m_tab->Lanes(); const std::size_t count = std::min(lanes.size(), tabLanes.size()); @@ -4541,12 +4526,11 @@ namespace jdi const bool meanFilter = (kind == EdgePanelKind::MEAN); const bool weakPeak = (kind == EdgePanelKind::PEAK); const bool freeMarker = (kind == EdgePanelKind::MARK); - // ⚠️ 「均值滤波」「弱峰调整」= 可滚动的分组列表(条目栏 + 展开后的两行滑块); - // **「中心轴边沿计数」= 表格**(表头:信号名 / 上升沿计数 / 下降沿计数),单独一支。 - // 三者都**不能**再走旧的逐条目分支:那个分支没有任何裁切,滚动时会画到标题栏上 - //(用户 2026-10-30 报告“元素超出浮动面板”的根因)。 - const bool listStyle = (meanFilter || weakPeak); - const bool centerTable = centerAxis; + // ⚠️ 「均值滤波」「中心轴边沿计数」「弱峰调整」= 可滚动的分组列表(条目栏 + 展开后的参数行); + // **「边沿计数」= 表格**(表头:信号名 / 上升沿计数 / 下降沿计数),单独一支。 + // 它们都**不能**再走旧的逐条目分支:那个分支没有任何裁切,滚动时会画到标题栏上。 + const bool listStyle = (meanFilter || centerAxis || weakPeak); + const bool countTable = (kind == EdgePanelKind::COUNT); const bool hasButton = (meanFilter || weakPeak); { if (index >= count) @@ -4678,7 +4662,6 @@ namespace jdi PanelTextCollector panelTextBoxes(UiGetTextRectSink()); const float titleHeight = kEdgePanelTitleHeight * m_scale; - const float rowHeight = kEdgePanelRowHeight * m_scale; const float pad = kEdgePanelPad * m_scale; // 以下整段(面板底色 → 标题栏 → 各控件 → 行内容)的画字都要走本地收集器。 @@ -4778,37 +4761,12 @@ namespace jdi if (!layout.collapsed) { - // 「中心轴边沿计数」= **表格**:表头(信号名 / 上升沿计数 / 下降沿计数)**固定不滚动**, + // 「边沿计数」= **表格**:表头(信号名 / 上升沿计数 / 下降沿计数)**固定不滚动**, // 下面每条可见信号一行,行底色 = 该信号的配色,三列与表头严格对齐。 - // 单击行(非复选框)展开 / 收起该信号自己的两行参数滑块。 - if (centerTable) + // 内容超高时右侧出现滚动条(只占内容区,不覆盖表头)。 + if (countTable) { - // 复选框自绘(在信号色叠底上用**自适应对比色**)。 - const auto paintTableCheck = [this, &g](const gd::RectF& rect, bool checked, - const UiInk& ink, - std::uint32_t background) { - if (rect.Width <= 0.0f || rect.Height <= 0.0f) - { - return; - } - const float radius = 2.5f * m_scale; - if (!checked) - { - UiDrawRoundRect(g, rect, radius, ink.text, 1.2f * m_scale); - return; - } - UiFillRoundRect(g, rect, radius, ink.text); - const float left = rect.X + rect.Width * 0.22f; - const float middleX = rect.X + rect.Width * 0.44f; - const float right = rect.X + rect.Width * 0.78f; - const float highY = rect.Y + rect.Height * 0.30f; - const float lowY = rect.Y + rect.Height * 0.72f; - UiDrawLine(g, left, rect.Y + rect.Height * 0.52f, middleX, lowY, background, - 1.6f * m_scale); - UiDrawLine(g, middleX, lowY, right, highY, background, 1.6f * m_scale); - }; - - // ---- 表头(固定;不参与滚动)---- + // ---- 表头 ---- { UiFillRoundRect(g, layout.tableHeader, 3.0f * m_scale, m_theme->pressed); UiDrawRoundRect(g, layout.tableHeader, 3.0f * m_scale, m_theme->border, 1.0f); @@ -4821,7 +4779,7 @@ namespace jdi } const float textLeft = (column == 0) ? (cell.left + 4.0f * m_scale) : cell.left; - UiTextClipped(g, EdgeCenterTableHeader(column), m_fonts->uiSmall, + UiTextClipped(g, EdgeCountTableHeader(column), m_fonts->uiSmall, gd::RectF(textLeft, layout.tableHeader.Y, cell.width, layout.tableHeader.Height), m_theme->textMuted, @@ -4834,18 +4792,10 @@ namespace jdi // 行内容必须裁到内容区:滚动时行会越出可视范围。 g.SetClip(layout.content, gd::CombineModeIntersect); - const float valueColumn = - std::max(UiMeasureTextWidth(m_fonts->monoSmall, L"100000"), - UiMeasureTextWidth(m_fonts->monoSmall, L"1000")); - const float sliderLabelColumn = - std::max(UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterPointsSliderLabel), - UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterIterationsSliderLabel)); - for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) { if (rowIndex >= layout.rowHeaders.size() || - rowIndex >= layout.rowChecks.size() || - rowIndex >= layout.rowTriangles.size()) + rowIndex >= layout.rowNameRects.size()) { break; } @@ -4856,80 +4806,33 @@ namespace jdi continue; } const gd::RectF& row = layout.rowHeaders[rowIndex]; - const Lane::EdgeCenterItem* item = tabLane.FindEdgeCenterItem(ref); - const bool checked = (item != nullptr); - // 整行(含展开的滑块)的纵向范围决定是否还需要绘制。 - float rowBottom = row.Y + row.Height; - if (rowIndex < layout.rowIterTracks.size() && - layout.rowIterTracks[rowIndex].Height > 0.0f) - { - rowBottom = std::max(rowBottom, layout.rowIterTracks[rowIndex].Y + - layout.rowIterTracks[rowIndex].Height); - } - if (rowIndex < layout.rowLevelTracks.size() && - layout.rowLevelTracks[rowIndex].Height > 0.0f) - { - rowBottom = std::max(rowBottom, layout.rowLevelTracks[rowIndex].Y + - layout.rowLevelTracks[rowIndex].Height); - } - if (rowBottom <= layout.content.Y || + if (row.Y + row.Height <= layout.content.Y || row.Y >= layout.content.Y + layout.content.Height) { continue; } - const std::uint32_t signalColor = StyleColor(*resolved.style); const UiInk ink = UiContrastInk(signalColor); - // 行底色 = 该信号的配色(用户 2026-11-02 要求);未勾选的行用普通底 - // + 弱文本,表示“尚未参与统计”。 - if (checked) - { - UiFillRoundRect(g, row, 3.0f * m_scale, signalColor); - UiFillRoundRect(g, row, 3.0f * m_scale, WithAlpha(signalColor, 0xE8)); - } - else - { - UiFillRoundRect(g, row, 3.0f * m_scale, m_theme->cardBg); - UiDrawRoundRect(g, row, 3.0f * m_scale, m_theme->border, 1.0f); - } - if (m_meanRowHoverLane == static_cast(index) && - m_meanRowHoverRef == ref && m_meanRowHoverKind == kind) - { - UiFillRoundRect(g, row, 3.0f * m_scale, - WithAlpha(m_theme->accent, 0x2E)); - } - - const std::uint32_t rowInk = checked ? ink.text : m_theme->text; - - // 展开 / 收起三角(仅已勾选时存在)。 - const gd::RectF& triangle = layout.rowTriangles[rowIndex]; - if (triangle.Width > 0.0f && checked) - { - UiDrawTriangleIcon(g, triangle, rowInk, - item->collapsed ? IconDirection::RIGHT - : IconDirection::DOWN); - } + // 行底色 = 该信号的配色(用户 2026-11-02 要求)。 + UiFillRoundRect(g, row, 3.0f * m_scale, signalColor); + UiFillRoundRect(g, row, 3.0f * m_scale, WithAlpha(signalColor, 0xE8)); // 信号名(第一列):超出列宽时省略。 const gd::RectF& nameRect = layout.rowNameRects[rowIndex]; UiTextClipped(g, UiEllipsize(resolved.data->name, m_fonts->uiSmall, std::max(8.0f, nameRect.Width)), - m_fonts->uiSmall, nameRect, rowInk, TextAlign::LEFT, + m_fonts->uiSmall, nameRect, ink.text, TextAlign::LEFT, layout.content); - // 复选框:位于名称单元的右端。 - paintTableCheck(layout.rowChecks[rowIndex], checked, ink, - checked ? signalColor : m_theme->cardBg); - // 两列计数:与表头列**同一左边界 / 同一宽度**,纵向严格对齐。 std::size_t rising = 0; std::size_t falling = 0; bool complete = true; - if (checked) + if (resolved.axis != nullptr) { - EdgeCenterCountsFor(*resolved.axis, *resolved.data, *resolved.style, - item->points, item->iterations, EdgeRangeActive(), - EdgeRangeMin(), EdgeRangeMax(), rising, falling, complete); + EdgeCountsFor(*resolved.axis, *resolved.data, *resolved.style, + tabLane.edgeValue, EdgeRangeActive(), EdgeRangeMin(), + EdgeRangeMax(), rising, falling, complete); } for (int column = 1; column < 3; ++column) { @@ -4938,109 +4841,12 @@ namespace jdi { continue; } - std::wstring text; - if (checked) - { - text = EdgeCountCellText((column == 1) ? rising : falling, complete); - } + const std::wstring text = + EdgeCountCellText((column == 1) ? rising : falling, complete); UiTextClipped(g, text, m_fonts->monoSmall, - gd::RectF(cell.left, row.Y, cell.width, row.Height), rowInk, + gd::RectF(cell.left, row.Y, cell.width, row.Height), ink.text, TextAlign::CENTER, layout.content); } - - // 展开后的两行参数滑块(均值点数 / 迭代次数)。 - if (!checked || item->collapsed) - { - continue; - } - const auto paintTableSlider = [&](const gd::RectF& track, - const gd::RectF& valueRect, - const gd::RectF& resetRect, - const wchar_t* label, int current, - int minimum, int maximum, bool active) { - if (track.Width <= 0.0f) - { - return; - } - const float labelLeft = - track.X - 6.0f * m_scale - sliderLabelColumn; - UiTextClipped(g, label, m_fonts->uiSmall, - gd::RectF(labelLeft, track.Y - 8.0f * m_scale, - sliderLabelColumn, track.Height + 16.0f * m_scale), - m_theme->textMuted, TextAlign::LEFT, layout.content); - const double ratio = - (maximum > minimum) - ? (static_cast(current - minimum) / - static_cast(maximum - minimum)) - : 0.0; - UiDrawSlider(g, track, ratio, m_theme->accent, active, m_scale); - UiTextClipped(g, std::to_wstring(current), m_fonts->monoSmall, - gd::RectF(valueRect.X, valueRect.Y, valueColumn, - valueRect.Height), - m_theme->textStrong, TextAlign::LEFT, layout.content); - const bool hot = - (m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) && - UiPointInRect(resetRect, static_cast(m_mouseX), - static_cast(m_mouseY)); - if (hot) - { - UiFillRoundRect( - g, gd::RectF(resetRect.X - 1.0f * m_scale, - resetRect.Y - 1.0f * m_scale, - resetRect.Width + 2.0f * m_scale, - resetRect.Height + 2.0f * m_scale), - 3.0f * m_scale, WithAlpha(m_theme->accent, 0x33)); - } - PaintPanelResetIcon(g, resetRect, - hot ? m_theme->accent : m_theme->textMuted, m_scale); - }; - - const int pointsFloor = 0; // 中心轴:0 = 不平滑(保持旧语义) - const int pointsMax = std::max( - pointsFloor, - std::min(kMeanFilterPointsMaxLimit, item->maxPoints)); - const int pointsValue = - std::max(pointsFloor, std::min(pointsMax, item->points)); - const int iterationsMax = - std::max(kMeanFilterIterationsMin, - std::min(kMeanFilterIterationsMaxLimit, item->maxIterations)); - const int iterationsValue = - std::max(kMeanFilterIterationsMin, - std::min(iterationsMax, item->iterations)); - const bool pointsActive = (m_dragMode == PlotDragMode::MEAN_POINTS) && - (m_meanDragLane == static_cast(index)) && - (m_meanDragRef == ref); - const bool iterationsActive = - (m_dragMode == PlotDragMode::MEAN_ITERATIONS) && - (m_meanDragLane == static_cast(index)) && (m_meanDragRef == ref); - if (rowIndex < layout.rowLevelTracks.size() && - layout.rowLevelTracks[rowIndex].Width > 0.0f) - { - paintTableSlider( - layout.rowLevelTracks[rowIndex], - rowIndex < layout.rowLevelValues.size() - ? layout.rowLevelValues[rowIndex] - : gd::RectF(), - rowIndex < layout.rowLevelResets.size() - ? layout.rowLevelResets[rowIndex] - : gd::RectF(), - kMeanFilterPointsSliderLabel, pointsValue, pointsFloor, pointsMax, - pointsActive); - } - if (rowIndex < layout.rowIterTracks.size() && - layout.rowIterTracks[rowIndex].Width > 0.0f) - { - paintTableSlider( - layout.rowIterTracks[rowIndex], - rowIndex < layout.rowIterValues.size() - ? layout.rowIterValues[rowIndex] - : gd::RectF(), - rowIndex < layout.rowIterResets.size() - ? layout.rowIterResets[rowIndex] - : gd::RectF(), - kMeanFilterIterationsSliderLabel, iterationsValue, - kMeanFilterIterationsMin, iterationsMax, iterationsActive); - } } g.Restore(tableSave); @@ -5055,7 +4861,6 @@ namespace jdi } } - // 「均值滤波」面板:逐信号的**信号色叠底标题栏**(左侧展开三角 + 信号名 + // 右端复选框)。复选框勾选后才出现三角、才能展开下面的两行参数滑块 // (「均值点数」/「迭代次数」);内容超高时可滚动(滚动条在内容区右侧)。 @@ -5156,12 +4961,14 @@ namespace jdi } const std::uint32_t signalColor = StyleColor(*resolved.style); const UiInk ink = UiContrastInk(signalColor); - // 「均值滤波」看 `meanFilterItems`、「弱峰调整」看 `weakPeakItems` - //(中心轴边沿计数已改为独立的表格分支)。 + // 「均值滤波」看 `meanFilterItems`、「中心轴边沿计数」看 `edgeCenterItems`、 + //「弱峰调整」看 `weakPeakItems`(前两者同为 `Lane::MeanFilterItem`)。 const Lane::WeakPeakItem* peakItem = weakPeak ? tabLane.FindWeakPeakItem(ref) : nullptr; const Lane::MeanFilterItem* item = - weakPeak ? nullptr : tabLane.FindMeanFilterItem(ref); + weakPeak ? nullptr + : (centerAxis ? tabLane.FindEdgeCenterItem(ref) + : tabLane.FindMeanFilterItem(ref)); const bool checked = weakPeak ? (peakItem != nullptr) : (item != nullptr); const bool rowCollapsed = weakPeak ? (peakItem != nullptr && peakItem->collapsed) @@ -5209,6 +5016,23 @@ namespace jdi 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; + if (resolved.axis != nullptr) + { + EdgeCenterCountsFor(*resolved.axis, *resolved.data, *resolved.style, + item->points, item->iterations, EdgeRangeActive(), + EdgeRangeMin(), EdgeRangeMax(), rising, falling, complete); + } + paintCounts(layout.rowStats[rowIndex], rising, falling, complete, + layout.content); + } + // 两行参数滑块(标签 / 轨道 / 数值 / 复位按钮)。 const auto paintMeanSlider = [&](const gd::RectF& track, const gd::RectF& valueRect, @@ -5756,64 +5580,6 @@ namespace jdi } } - // 「边沿计数」:逐信号两行(名称 / 上升沿·下降沿)。内容区**可滚动且按内容区裁剪** - //(上百个信号时内容高达上万像素:限高后出现滚动条,面板高度被绘图区夹住, - // 面板像素缓存才存得进去 —— 否则每帧整块重画,所有交互都会卡顿)。 - { - const int contentState = g.Save(); - g.SetClip(layout.content, gd::CombineModeIntersect); - float rowY = layout.content.Y - layout.scrollOffset; - for (const SignalRef& ref : tabLane.signals) - { - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data == nullptr || resolved.style == nullptr || !resolved.visible) - { - continue; - } - if (freeMarker || listStyle || centerTable) - { - // 这些排布已在上面画完(各自有自己的可滚动 / 表格分支,含裁切)。 - continue; - } - if (rowY + rowHeight * 2.0f <= layout.content.Y || - rowY >= layout.content.Y + layout.content.Height) - { - rowY += rowHeight * 2.0f; // 整行滚出可视范围 - continue; - } - const std::uint32_t signalColor = StyleColor(*resolved.style); - const float textWidth = layout.content.Width; - // 名称:超长时省略(面板宽度被 `kEdgePanelNameMaxWidth` 限制,不会把面板顶得很长)。 - // ⚠️ GDI 文本必须走 `UiTextClipped`,否则滚动时会画到标题栏 / 面板之外。 - UiTextClipped(g, - UiEllipsize(resolved.data->name, m_fonts->uiSmall, - std::min(textWidth, kEdgePanelNameMaxWidth * m_scale)), - m_fonts->uiSmall, - gd::RectF(layout.content.X, rowY, textWidth, rowHeight), - signalColor, TextAlign::LEFT, layout.content); - - std::size_t rising = 0; - std::size_t falling = 0; - bool complete = true; - EdgeCountsFor(*resolved.axis, *resolved.data, *resolved.style, - tabLane.edgeValue, EdgeRangeActive(), EdgeRangeMin(), - EdgeRangeMax(), rising, falling, complete); - paintCounts( - gd::RectF(layout.content.X, rowY + rowHeight, textWidth, rowHeight), rising, - falling, complete, layout.content); - rowY += rowHeight * 2.0f; - } - g.Restore(contentState); - - // 内容滚动条(只在内容超高时出现)。 - if (layout.scrollTrack.Width > 0.0f) - { - UiFillRoundRect(g, layout.scrollTrack, layout.scrollTrack.Width * 0.5f, - WithAlpha(m_theme->border, 0x88)); - UiFillRoundRect(g, layout.scrollThumb, layout.scrollThumb.Width * 0.5f, - m_theme->borderStrong); - } - } // 逐条目面板底部按钮行:「构建为新信号」(悬停高亮); // 「自由标记」面板的底部固定区有三行: diff --git a/Components/PlotView/Src/PlotViewPanels.cpp b/Components/PlotView/Src/PlotViewPanels.cpp index 240c454..6cfb37f 100644 --- a/Components/PlotView/Src/PlotViewPanels.cpp +++ b/Components/PlotView/Src/PlotViewPanels.cpp @@ -28,12 +28,13 @@ namespace jdi const bool centerAxis = (kind == EdgePanelKind::CENTER); const bool meanFilter = (kind == EdgePanelKind::MEAN); const bool weakPeak = (kind == EdgePanelKind::PEAK); - // 「均值滤波」「弱峰调整」= 可滚动的分组列表(信号色叠底条目栏 + 右侧复选框 + - // 展开后的两行滑块);**「中心轴边沿计数」= 表格**(信号名 / 上升沿计数 / 下降沿计数)。 - // ⚠️ 旧式「逐条目」排版(名称 + 复选框 + 参数行,无滚动)已彻底删除: - // 上百个信号时它会算出上万像素高的面板,超出绘图区后**面板像素缓存一次都存不进去** - // ⇒ 每一帧整块重画 ⇒ 所有交互卡顿(用户 2026-11-02 报告)。 - const bool listStyle = (meanFilter || weakPeak); + // 「均值滤波」「中心轴边沿计数」「弱峰调整」= 可滚动的分组列表 + //(信号色叠底条目栏 + 右侧复选框 + 展开后的参数行); + // **「边沿计数」= 表格**(表头:信号名 / 上升沿计数 / 下降沿计数)。 + // ⚠️ 旧式「逐条目」排版(无滚动的名称+参数行)已彻底删除:上百个信号时它会算出 + // 上万像素高的面板,超出绘图区后**面板像素缓存一次都存不进去** ⇒ 每帧整块重画。 + const bool listStyle = (centerAxis || meanFilter || weakPeak); + const bool countTable = (kind == EdgePanelKind::COUNT); // 标题行 = “GX” 徽标 + 标题(“边沿计数” / “中心轴边沿计数” / “均值滤波” / “弱峰调整” // + 光标区间尾缀)。 @@ -78,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) { @@ -95,49 +111,37 @@ namespace jdi EdgeCenterCountsFor(*resolved.axis, *resolved.data, *resolved.style, item->points, item->iterations, EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling, complete); + const float pendingWidth = UiMeasureTextWidth(m_fonts->monoSmall, L"统计中…"); + statRisingWidth = std::max( + statRisingWidth, + std::max(UiMeasureTextWidth(m_fonts->monoSmall, EdgeRisingText(rising, complete)), + pendingWidth)); + statFallingWidth = std::max( + statFallingWidth, + UiMeasureTextWidth(m_fonts->monoSmall, EdgeFallingText(falling, complete))); } - else - { - EdgeCountsFor(*resolved.axis, *resolved.data, *resolved.style, lane.edgeValue, - EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling, complete); - } - // 宽度按实际要显示的文本测量(含未算完时的“…”),否则推进过程中数字变长会被截断。 - // 完全未算出时整行显示“统计中…”,因此两列都要预留它的宽度。 - const float pendingWidth = UiMeasureTextWidth(m_fonts->monoSmall, L"统计中…"); - statRisingWidth = std::max( - statRisingWidth, - std::max(UiMeasureTextWidth(m_fonts->monoSmall, EdgeCountCellText(rising, complete)), - pendingWidth)); - statFallingWidth = - std::max(statFallingWidth, - UiMeasureTextWidth(m_fonts->monoSmall, EdgeCountCellText(falling, complete))); } - if (centerAxis) + if (countTable) { - // 「中心轴边沿计数」= **表格**(信号名 / 上升沿计数 / 下降沿计数): - // 宽度 = 复选框 + 名称列 + 两列计数(列宽取「表头文字」「实际数字」的较大者)+ 滚动条。 + // 「边沿计数」= **表格**(信号名 / 上升沿计数 / 下降沿计数): + // 宽度 = 名称列 + 两列计数(列宽取「表头文字」「实际数字」的较大者)+ 滚动条留位。 const float headerRising = - UiMeasureTextWidth(m_fonts->uiSmall, EdgeCenterTableHeader(1)); + UiMeasureTextWidth(m_fonts->uiSmall, EdgeCountTableHeader(1)); const float headerFalling = - UiMeasureTextWidth(m_fonts->uiSmall, EdgeCenterTableHeader(2)); + 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 + - kEdgePanelDisclosureWidth * m_scale + 6.0f * m_scale + - kEdgePanelMeanCheckSize * m_scale + 6.0f * m_scale + - std::min(nameColumnWidth, kEdgePanelNameMaxWidth * m_scale) + - gap + risingColumn + gap + fallingColumn + + nameColumnWidth + gap + risingColumn + gap + fallingColumn + (kFreeMarkerScrollWidth + 4.0f) * m_scale); } - else if (statRisingWidth > 0.0f || statFallingWidth > 0.0f) + 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 (listStyle) @@ -170,18 +174,18 @@ namespace jdi width = std::max(width, kEdgePanelPad * 2.0f * m_scale + rowWidth); } - if (listStyle) + 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 (kind == EdgePanelKind::COUNT) + if (countTable) { - // 「边沿计数」:右侧要给内容滚动条留位(上百个信号时内容必然超高)。 - width = std::max(width, kEdgePanelPad * 3.0f * m_scale + + // 「边沿计数」表格:右侧要给内容滚动条留位(上百个信号时内容必然超高)。 + width = std::max(width, kEdgePanelPad * 2.0f * m_scale + (kFreeMarkerScrollWidth + 4.0f) * m_scale); } @@ -272,10 +276,10 @@ namespace jdi const bool meanFilter = (kind == EdgePanelKind::MEAN); const bool weakPeak = (kind == EdgePanelKind::PEAK); const bool freeMarker = (kind == EdgePanelKind::MARK); - // 「均值滤波」与「弱峰调整」共用同一套**可滚动分组列表**排布(信号色叠底条目栏 + - // 展开后的两行滑块);「中心轴边沿计数」自 2026-11-02 起单独走**表格**分支(`centerTable`)。 - const bool listStyle = (meanFilter || weakPeak); - const bool centerTable = centerAxis; + // 「均值滤波」「中心轴边沿计数」「弱峰调整」共用同一套**可滚动分组列表**排布 + //(信号色叠底条目栏 + 展开后的参数行);「边沿计数」自 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 @@ -317,16 +321,11 @@ 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 = 2.0f; // 「边沿计数」每信号两行(名称 / 上升沿·下降沿) - // 面板底部(内容区之下)占的纵向空间:有按钮时 = pad + 按钮 + pad, - // 无按钮时只有 pad。⚠️ 旧实现无条件按「按钮行 + 三个 pad」预留 —— - // 无按钮的面板(中心轴边沿计数)因此在内容区下方留下**一大块空白** - //(= 按钮高度 30 逻辑像素 + 多出的 pad,用户 2026-11-02 报告)。 + // 面板底部(内容区之下)占的纵向空间:有按钮时 = pad + 按钮 + pad,无按钮时只有 pad。 const float footerHeight = hasButton ? (buttonHeight + pad) : 0.0f; const float bottomPad = pad; @@ -334,31 +333,12 @@ namespace jdi float meanContentHeight = 0.0f; float height = 0.0f; float tableHeaderHeight = 0.0f; - if (centerTable) + if (countTable) { - // 「中心轴边沿计数」= **表格**:表头(信号名 / 上升沿计数 / 下降沿计数)固定不滚动, - // 下面每条**已勾选**的信号占一行(行底色 = 信号配色),单击行展开两行参数滑块。 - // 默认只显示表格行(`EnsureEdgeCenterItem` 新建条目时 `collapsed = true`)。 + // 「边沿计数」= **表格**:表头(信号名 / 上升沿计数 / 下降沿计数)固定不滚动, + // 下面每条可见信号占一行(行底色 = 信号配色),内容超高时滚动。 const float rowH = kEdgePanelMeanRowHeight * m_scale; - float content = 0.0f; - for (const SignalRef& ref : lane.signals) - { - const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data == nullptr || !resolved.visible) - { - continue; - } - const Lane::EdgeCenterItem* item = lane.FindEdgeCenterItem(ref); - if (item == nullptr && !resolved.visible) - { - continue; - } - content += rowH; - if (item != nullptr && !item->collapsed) - { - content += weakPeakSliderRow * 2.0f; - } - } + const float content = static_cast(rowCount) * rowH; meanContentHeight = content; tableHeaderHeight = rowH; const float budget = @@ -371,8 +351,10 @@ namespace jdi } else if (listStyle) { - // 「均值滤波 / 弱峰调整」:内容区 = 逐信号的**信号色叠底条目栏** - //(内容总是显示,用于勾选/取消)+(勾选且未收起时)两行参数滑块。 + // 「均值滤波 / 中心轴边沿计数 / 弱峰调整」:内容区 = 逐信号的**信号色叠底条目栏** + //(内容总是显示,用于勾选/取消)+(勾选且未收起时)参数行。 + // 「中心轴」的参数行 = 一行「上升沿 / 下降沿」统计 + 两行滑块; + // 「均值滤波」与「弱峰调整」= 两行滑块。 // 内容超高时出现滚动条;底部「构建为新信号」按钮固定不滚动。 const float groupRow = kEdgePanelMeanRowHeight * m_scale; const float sliderRow = weakPeakSliderRow; @@ -391,6 +373,11 @@ namespace jdi 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); @@ -399,6 +386,10 @@ namespace jdi if (expanded) { content += sliderRow * 2.0f; + if (centerAxis) + { + content += groupRow; // 统计行(上升沿 / 下降沿) + } } } meanContentHeight = content; @@ -417,14 +408,8 @@ namespace jdi } else { - // 「边沿计数」:每信号两行(名称 / 上升沿·下降沿),内容超高时滚动。 - const float content = static_cast(rowCount) * rowHeight * rowsPerSignal; - meanContentHeight = content; - const float budget = m_plotRect.Height - (titleHeight + pad + bottomPad); - const float maxContent = - std::max(2.0f * rowHeight, std::min(kFreeMarkerContentMaxHeight * m_scale, budget)); - const float view = std::min(content, maxContent); - height = titleHeight + pad + view + bottomPad; + // 不应到达:所有面板种类都已覆盖。保留一个保守的高度以免布局全零。 + height = titleHeight + pad + bottomPad; } // 「自由标记」面板:按信号分组(**分组栏** + 下属固定点条目), @@ -598,18 +583,17 @@ namespace jdi { return layout; } - if (centerTable) + if (countTable) { - // 「中心轴边沿计数」= **表格**:表头固定、内容区可滚动。 - // 列:① 信号名(含复选框与展开三角)② 上升沿计数 ③ 下降沿计数。 + // 「边沿计数」= **表格**:表头固定、内容区可滚动。 + // 列:① 信号名 ② 上升沿计数 ③ 下降沿计数(行底色 = 信号配色)。 const float rowH = kEdgePanelMeanRowHeight * m_scale; - const float sliderRow = kWeakPeakSliderRowHeight * 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.edgeCenterScroll, maxScroll)); + 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 = @@ -631,32 +615,32 @@ namespace jdi (viewHeight - thumbHeight) * ((maxScroll > 0.0f) ? (scroll / maxScroll) : 0.0f); layout.scrollThumb = gd::RectF(layout.scrollTrack.X, thumbTop, scrollW, thumbHeight); } - - // 三列:名称列吃掉剩余宽度,两列计数按内容宽度固定右对齐 —— 表头与数据行严格对齐。 - const float closeSize = kEdgePanelMeanCheckSize * m_scale; + // 三列:名称列吃掉剩余宽度,两列计数按内容宽度固定 —— 表头与数据行严格对齐。 const float gap = kEdgePanelStatColumnGap * m_scale; const float contentLeft = layout.content.X; // 计数列宽 = 「表头文字」「当前数字」的较大者(与 `EdgePanelWidth` 同源)。 - float risingCol = UiMeasureTextWidth(m_fonts->uiSmall, EdgeCenterTableHeader(1)) + 10.0f * m_scale; - float fallingCol = UiMeasureTextWidth(m_fonts->uiSmall, EdgeCenterTableHeader(2)) + 10.0f * m_scale; + 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); - const Lane::EdgeCenterItem* item = lane.FindEdgeCenterItem(ref); - if (resolved.data == nullptr || resolved.axis == nullptr || resolved.style == nullptr || - item == nullptr) + 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; - EdgeCenterCountsFor(*resolved.axis, *resolved.data, *resolved.style, item->points, - item->iterations, EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), - rising, falling, complete); - risingCol = std::max(risingCol, - UiMeasureTextWidth(m_fonts->monoSmall, EdgeCountCellText(rising, complete)) + - 10.0f * m_scale); + 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)) + @@ -670,21 +654,6 @@ namespace jdi layout.tableColumns[1].width = risingCol; layout.tableColumns[2].left = layout.tableColumns[1].left + risingCol + gap; layout.tableColumns[2].width = fallingCol; - - // 参数行(两行滑块)的列宽:与均值滤波同一套算式。 - const float labelColumn = - std::max(UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterPointsSliderLabel), - UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterIterationsSliderLabel)); - const float valueColumn = UiMeasureTextWidth(m_fonts->monoSmall, L"100000"); - const float resetSize = kWeakPeakResetButtonSize * m_scale; - const float resetGap = kWeakPeakResetGap * m_scale; - const float trackHeight = kWeakPeakTrackHeight * m_scale; - const float trackLeft = contentLeft + labelColumn + 6.0f * m_scale; - const float resetLeft = contentLeft + innerWidth - resetSize; - const float trackWidth = - std::max(10.0f * m_scale, - (resetLeft - resetGap - valueColumn - 6.0f * m_scale) - trackLeft); - float rowY = layout.content.Y - scroll; for (const SignalRef& ref : lane.signals) { @@ -693,63 +662,23 @@ namespace jdi { continue; } - const Lane::EdgeCenterItem* item = lane.FindEdgeCenterItem(ref); - if (item == nullptr && !resolved.visible) - { - continue; - } - // 未勾选(`item == nullptr`)的行也列出来:它是“启用统计”的唯一入口 - //(表格模式下没有独立的开关;点行内复选框即启用 / 取消)。 - const bool checked = (item != nullptr); - const bool expanded = (item != nullptr && !item->collapsed); layout.rowRefs.push_back(ref); layout.rowHeaders.push_back(gd::RectF(contentLeft, rowY, innerWidth, rowH)); - layout.rowNameRects.push_back(gd::RectF( - layout.tableColumns[0].left + kEdgePanelDisclosureWidth * m_scale + 4.0f * m_scale, - rowY, std::max(10.0f, nameColumn - closeSize - kEdgePanelDisclosureWidth * m_scale), - rowH)); - layout.rowTriangles.push_back( - checked ? gd::RectF(contentLeft + 2.0f * m_scale, - rowY + (rowH - kEdgePanelRowTriangleSize * m_scale) * 0.5f, - kEdgePanelRowTriangleSize * m_scale, - kEdgePanelRowTriangleSize * m_scale) - : gd::RectF()); - layout.rowChecks.push_back(gd::RectF( - contentLeft + nameColumn - closeSize - 2.0f * m_scale, - rowY + (rowH - closeSize) * 0.5f, closeSize, closeSize)); + 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; - - if (!expanded) - { - 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()); - continue; - } - const gd::RectF pointsTrack(trackLeft, rowY + (sliderRow - trackHeight) * 0.5f, - trackWidth, trackHeight); - layout.rowLevelTracks.push_back(pointsTrack); - layout.rowLevelValues.push_back( - gd::RectF(pointsTrack.X + pointsTrack.Width + 6.0f * m_scale, rowY, valueColumn, - sliderRow)); - layout.rowLevelResets.push_back( - gd::RectF(resetLeft, rowY + (sliderRow - resetSize) * 0.5f, resetSize, resetSize)); - rowY += sliderRow; - const gd::RectF iterTrack(trackLeft, rowY + (sliderRow - trackHeight) * 0.5f, - trackWidth, trackHeight); - layout.rowIterTracks.push_back(iterTrack); - layout.rowIterValues.push_back( - gd::RectF(iterTrack.X + iterTrack.Width + 6.0f * m_scale, rowY, valueColumn, - sliderRow)); - layout.rowIterResets.push_back( - gd::RectF(resetLeft, rowY + (sliderRow - resetSize) * 0.5f, resetSize, resetSize)); - rowY += sliderRow; } } else if (listStyle) @@ -771,7 +700,8 @@ namespace jdi 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 storedScroll = meanFilter ? lane.meanFilterScroll : lane.weakPeakScroll; + 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)); @@ -827,6 +757,12 @@ namespace jdi 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); @@ -863,7 +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); @@ -885,32 +826,6 @@ namespace jdi rowY += sliderRow; } } - else if (!freeMarker) - { - // 「边沿计数」:内容区 = 逐信号两行(名称 / 上升沿·下降沿),可滚动。 - const float scrollW = kFreeMarkerScrollWidth * m_scale; - const float viewHeight = std::max(0.0f, height - titleHeight - pad - 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.content = gd::RectF(left + pad, top + titleHeight + pad, 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); - } - } if (freeMarker) { @@ -1704,4 +1619,4 @@ namespace jdi return probe; } -} // namespace jdi +} // namespace jdi \ No newline at end of file diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index 70700e5..556ab07 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -9310,3 +9310,96 @@ make release / make debug 零 warning;Release 813,568 B(< 1MB 红线) 要验证"某个面板的悬停"必须先把另一块的 `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 数据文件再读取。 diff --git a/Utilities/Src/DataModel.cpp b/Utilities/Src/DataModel.cpp index 964a5fb..419918f 100644 --- a/Utilities/Src/DataModel.cpp +++ b/Utilities/Src/DataModel.cpp @@ -1349,10 +1349,6 @@ namespace jdi } EdgeCenterItem item; item.ref = ref; - // 「中心轴边沿计数」面板自 2026-11-02 起是**表格**(信号名 / 上升沿计数 / 下降沿计数): - // 新增条目默认**只显示一行表格行**,参数滑块(均值点数 / 迭代次数)收起,单击该行展开。 - // (均值滤波 / 弱峰调整的条目仍默认展开,它们的语义未变。) - item.collapsed = true; edgeCenterItems.push_back(item); return edgeCenterItems.back(); }