diff --git a/Components/Application/Inc/Application.hpp b/Components/Application/Inc/Application.hpp index 5244a7c..a7430f6 100644 --- a/Components/Application/Inc/Application.hpp +++ b/Components/Application/Inc/Application.hpp @@ -140,7 +140,12 @@ namespace jdi /** * @brief 新建标签页并切换为激活页。 */ - void NewTab(); + /** + * @brief 新建一个标签页并切换到它。 + * @param [IN] collapseGroups 是否把信号列表里的分组**默认折起** + *(用户新建的标签页传 true;启动时的首个标签页传 false,便于直接勾选信号)。 + */ + void NewTab(bool collapseGroups); /** * @brief 生成当前未被占用的最小标签页名称(关闭标签页后编号可被复用)。 diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index c511a20..fe5d0bf 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -51,15 +51,50 @@ namespace jdi const UINT kRamTimerPeriodMs = 1000; /**< 标题栏内存占用的刷新间隔(1 秒)。 */ /** - * @brief 查询本进程当前占用的物理内存(工作集)。 + * @brief 查询本进程当前占用的内存(**私有工作集**,与任务管理器"内存"列同口径)。 * - * 与任务管理器里显示的“内存”一致:进程已驻留在物理内存中的字节数。 - * 使用 `K32GetProcessMemoryInfo`(kernel32 导出)以免为 `psapi` 增加一条链接依赖。 + * ⚠️ 原先取的是 `PROCESS_MEMORY_COUNTERS::WorkingSetSize`(**总工作集**),它把映射进来的 + * DLL / 共享页面以及已经释放但尚未被系统回收的驻留页面都算进去 —— 加载一个大文件再 + * 关闭分组后,实际占用的私有内存已经回到几 MB,标题栏却还显示几十 MB, + * 与任务管理器(资源管理器里看到的"内存")对不上(用户报告)。 + * `PROCESS_MEMORY_COUNTERS_EX2::PrivateWorkingSetSize` 正是任务管理器"内存"列的口径: + * 只统计**属于本进程的私有、且驻留在物理内存中**的页面。 * - * @return 工作集字节数;查询失败返回 0。 + * 结构体在这里就地声明(MinGW 的 psapi.h 未提供 `PROCESS_MEMORY_COUNTERS_EX2`), + * 布局与 Win32 文档一致;EX2 查询失败(老系统)时退回 `PrivateUsage`(私有提交), + * 再不行退回 `WorkingSetSize`,保证标题栏永远有值。 + * + * @return 内存占用字节数;查询失败返回 0。 */ - unsigned long long ProcessWorkingSetBytes() + unsigned long long ProcessPrivateWorkingSetBytes() { + struct MemoryCountersEx2 + { + DWORD cb; + DWORD PageFaultCount; + SIZE_T PeakWorkingSetSize; + SIZE_T WorkingSetSize; + SIZE_T QuotaPeakPagedPoolUsage; + SIZE_T QuotaPagedPoolUsage; + SIZE_T QuotaPeakNonPagedPoolUsage; + SIZE_T QuotaNonPagedPoolUsage; + SIZE_T PagefileUsage; + SIZE_T PeakPagefileUsage; + SIZE_T PrivateUsage; + SIZE_T PrivateWorkingSetSize; + SIZE_T SharedCommitUsage; + }; + + MemoryCountersEx2 ex2 = {}; + ex2.cb = sizeof(ex2); + if (K32GetProcessMemoryInfo(GetCurrentProcess(), + reinterpret_cast(&ex2), + sizeof(ex2)) != FALSE && + ex2.PrivateWorkingSetSize > 0) + { + return static_cast(ex2.PrivateWorkingSetSize); + } + PROCESS_MEMORY_COUNTERS_EX counters = {}; counters.cb = sizeof(counters); if (K32GetProcessMemoryInfo(GetCurrentProcess(), @@ -68,6 +103,10 @@ namespace jdi { return 0ull; } + if (counters.PrivateUsage > 0) + { + return static_cast(counters.PrivateUsage); + } return static_cast(counters.WorkingSetSize); } @@ -374,8 +413,8 @@ namespace jdi SendMessageW(m_hwnd, WM_SETICON, ICON_SMALL, reinterpret_cast(smallIcon)); } - // 创建首个标签页。 - NewTab(); + // 创建首个标签页(分组保持展开:加载文件后直接就能勾选信号)。 + NewTab(false); Relayout(); // 标题栏内存占用按固定间隔刷新(不使用每帧刷新,避免频繁改动窗口标题)。 @@ -443,11 +482,18 @@ namespace jdi return m_tabs[static_cast(m_activeTab)]; } - void Application::NewTab() + void Application::NewTab(bool collapseGroups) { TabConfig tab; tab.SetName(NextTabName()); + // 用户新建的标签页:信号分组**默认折起**(用户明确要求:列表默认紧凑; + // 之后在该标签页里新加载的分组也沿用这个默认值)。 + tab.SetGroupsCollapsedByDefault(collapseGroups); tab.SyncToDataSet(m_dataSet); + if (collapseGroups) + { + tab.SetAllCollapsed(true); + } m_tabs.push_back(std::move(tab)); SetActiveTab(static_cast(m_tabs.size()) - 1); @@ -899,10 +945,11 @@ namespace jdi { // 标题栏 = 应用名称 + 分隔竖线 + 内存占用(单位在 KB/MB/GB 间自适应,保留两位小数)。 // 除内存占用外不再附带标签页、文件数量等无关信息。 + // ⚠️ 内存口径 = **私有工作集**(与任务管理器"内存"列一致),不是总工作集。 std::wstring title = kWindowTitle; title += kTitleSeparator; title += kRamTitlePrefix; - title += FormatByteSize(ProcessWorkingSetBytes()); + title += FormatByteSize(ProcessPrivateWorkingSetBytes()); // 数值未变化时不调用 SetWindowTextW,避免无谓地重绘标题栏。 if (title == m_windowTitleText) { @@ -1282,7 +1329,7 @@ namespace jdi } if (control && key == L'T') { - NewTab(); + NewTab(true); return true; } if (control && key == L'W') @@ -1412,7 +1459,7 @@ namespace jdi switch (result.type) { case TabBarResult::Type::ADD: - NewTab(); + NewTab(true); break; case TabBarResult::Type::SELECT: if (result.index != m_activeTab) @@ -2050,7 +2097,7 @@ namespace jdi const int index = m_tabMenuIndex; if (result.id == kMenuTabNew) { - NewTab(); + NewTab(true); } else if (result.id == kMenuTabClose) { diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index f491eed..3144110 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -1601,9 +1601,14 @@ namespace jdi * * ⚠️ 因此判定值必须是**画出来的值**:先按 `style` 做水平镜像映射,再套用 * `ApplyDrawTransform`(系数 → 绝对值 → 垂直镜像 → Y 偏移)。 - * 只比较原始采样值会导致「应用过调整的信号数不到任何边沿」 - *(横向光标读的是屏幕上的值,与原始值域完全不同)。 - * ⚠️ 时间区间**之外**的采样一律不参与计数:只有两个端点都在区间内的采样对才计入。 + * 只比较原始采样值会导致「应用过调整的信号数不到任何边沿」。 + * ⚠️ 时间区间**之外**的采样一律不参与计数(只有两个端点都在区间内的采样对才计入)。 + * + * ⚠️ **本函数绝不长时间阻塞 UI 线程**(千万级信号整段扫描实测 ≈115ms/条, + * 横向光标每一帧都会换阈值 → 拖动时每帧都要扫,5 条信号就是 ≈575ms/帧): + * 扫描按「逐块累积」进行,每个 tick(约一帧)只消耗 `kEdgeScanPairsPerTick` + * 个采样对的预算,未扫完时 `complete` 返回 false(界面显示上一个完整结果 + “…”)。 + * 拖动横向光标(`EDGE_MOVE`)期间阈值每帧都变,推进等于白做功 → 不推进。 * * @param [IN] file 信号所属文件。 * @param [IN] signal 目标信号。 @@ -1614,10 +1619,11 @@ namespace jdi * @param [IN] rangeMax 区间右端点时间。 * @param [OUT] rising 上升沿数量(从下往上穿越)。 * @param [OUT] falling 下降沿数量(从上往下穿越)。 + * @param [OUT] complete 结果是否为该阈值的**完整**精确值(false = 仍在推进,当前是临时值)。 */ void EdgeCountsFor(const DataFile& file, const SignalData& signal, const SignalStyle& style, double edgeValue, bool hasRange, double rangeMin, double rangeMax, - std::size_t& rising, std::size_t& falling) const; + std::size_t& rising, std::size_t& falling, bool& complete) const; /** * @brief 计算实验性功能面板所需的宽度(排版与绘制共用,保证文字完整不被省略)。 @@ -1819,28 +1825,37 @@ namespace jdi std::vector m_lanes; /**< 当前网格绘制信息集合。 */ /** - * @brief 边沿统计缓存项。 + * @brief 「边沿计数」的累积统计条目。 * - * 计数只由「采样下标区间 [begin, end) 与判定基准(横向光标幅值)」决定, - * 因此**时间区间不进缓存键**:拖动时间光标时区间每帧都在变,若按时间区间做键就会 - * 每帧全量重扫整段区间(亿级采样下正是拖拽卡顿的主因,实测单帧数十毫秒)。 - * 改为记住「上一帧统计过的下标区间」:新区间与旧区间共享一个端点时(拖动时必然如此) - * 只扫描两端差集并增量加减,单帧代价与光标位移成正比、与区间长度无关。 + * 千万级信号整段扫描一次就要 ≈115ms,而横向光标拖动时**每一帧**都会换阈值 + *(5 条信号同一网格实测 ≈575ms/帧,界面直接失去响应)。 + * 因此统计改为「逐块累积 + 每 tick 有限预算推进」(与 `RenderPendingLaneRebuilds(1)` + * 同一套「宁可晚一点、绝不卡住前台」的思路): + * * 采样对 `k`(采样 `k-1 → k`)按 `kEdgeBlockSamples` 分块,每块各存上升/下降沿数; + * * `scannedPair` = 下一个待扫描的采样对,[1, scannedPair) 已扫完; + * * 查询某个区间时,内部整块用存储值相加、两端边界块精确扫描(≤ 2 块,代价固定); + * * 时间区间**不进键**(单调时间轴下区间只是查询区间,拖动时间光标无需重扫), + * 但非单调时间轴无法用下标区间表达统计范围,此时区间必须进键(否则会混用结果)。 */ struct EdgeCountEntry { const DataFile* file = nullptr; /**< 所属数据分组。 */ const SignalData* signal = nullptr; /**< 目标信号。 */ - std::uint64_t styleKey = 0; /**< 「调整」变换 + 水平镜像的指纹(变了必须整段重算)。 */ - double edgeValue = 0.0; /**< 判定阈值(横向光标幅值)。 */ - bool hasRange = false; /**< 是否限定在时间区间内统计。 */ - bool reusable = false; /**< 计数是否完全由 [begin, end) 决定。 */ - std::size_t begin = 0; /**< 已统计的采样区间起点(含)。 */ - std::size_t end = 0; /**< 已统计的采样区间终点(不含)。 */ - std::size_t rising = 0; /**< 该区间内的上升沿数量。 */ - std::size_t falling = 0; /**< 该区间内的下降沿数量。 */ + std::uint64_t styleKey = 0; /**< 「调整」变换 + 水平镜像的指纹。 */ + double threshold = 0.0; /**< 判定阈值(横向光标幅值)。 */ + bool hasRange = false; /**< 是否限定在时间区间内(仅非单调时间轴时进键)。 */ + double rangeMin = 0.0; /**< 统计区间左端(仅非单调时间轴时进键)。 */ + double rangeMax = 0.0; /**< 统计区间右端(仅非单调时间轴时进键)。 */ + std::vector blockRising; /**< 每块的上升沿数量(已扫到的部分)。 */ + std::vector blockFalling; /**< 每块的下降沿数量(已扫到的部分)。 */ + std::size_t scannedPair = 1; /**< 下一个待扫描的采样对下标([1, scannedPair) 已扫完)。 */ + std::size_t rising = 0; /**< 已扫部分的上升沿总数。 */ + std::size_t falling = 0; /**< 已扫部分的下降沿总数。 */ + bool complete = false; /**< 整条信号是否已扫完。 */ }; - mutable std::vector m_edgeCountCache; /**< 边沿统计缓存(纯缓存,逻辑上不影响对象状态)。 */ + mutable std::vector m_edgeCountCache; /**< 边沿统计累积缓存(纯缓存,逻辑上不影响对象状态)。 */ + mutable std::size_t m_edgeScanBudget = 0; /**< 本 tick 剩余可扫描的采样对数量(限制单帧总开销)。 */ + mutable std::uint64_t m_edgeScanTick = 0; /**< 最近一次重置扫描预算的 tick。 */ /** 多次迭代均值滤波曲线缓存项(按「信号指针 + 均值点数 + 迭代次数」标识)。 */ struct MeanCurveEntry diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index 48316c2..1635744 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -149,6 +149,42 @@ namespace jdi const wchar_t* const kWeakPeakTitle = L"弱峰调整"; /**< 弱峰调整面板标题。 */ const wchar_t* const kBuildSignalLabel = L"构建为新信号"; /**< 逐条目面板底部按钮文案(均值滤波 / 弱峰调整共用)。 */ const std::size_t kEdgeCountCacheLimit = 48; /**< 边沿统计缓存条目上限(超出时淘汰最早的一条)。 */ + /** + * @brief 「边沿计数」累积统计的分块大小(采样点数)。 + * + * 每块各存上升/下降沿数:查询某个时间区间时,内部整块用存储值相加、只有两端边界块 + * 需要就地精确扫描(≤ 2 块),因此拖动时间光标的单帧代价与区间长度无关。 + * 取小一点 ⇒ 边界块扫描更便宜;取大一点 ⇒ 块数组更省内存。 + */ + const std::size_t kEdgeBlockSamples = 8192; + /** + * @brief 每个 tick(约一帧)允许「边沿计数」扫描的采样对总数。 + * + * 千万级信号整段扫描一次 ≈115ms(用户报告:5 条信号同一网格时单帧 ≈575ms, + * 界面完全失去响应)。改成预算推进后,单帧最多只花 ≈5ms 在统计上, + * 剩下的部分在后续帧里接着扫(未扫完时界面显示上一个完整结果 + “…”)。 + */ + const std::size_t kEdgeScanPairsPerTick = 1200000; + /** + * @brief 「边沿计数」结果的显示文本。 + * @param [IN] rising 上升沿数量。 @param [IN] falling 下降沿数量。 + * @param [IN] complete 是否为该阈值的完整精确值。 + * @return 面板/宽度测量共用的文本(未算完时带省略号,完全未开始时显示“统计中…”)。 + */ + std::wstring EdgeCountsText(std::size_t rising, std::size_t falling, bool complete) + { + if (!complete && rising == 0 && falling == 0) + { + return L"统计中…"; + } + std::wstring text = L"上升沿 " + std::to_wstring(rising) + L" 下降沿 " + + std::to_wstring(falling); + if (!complete) + { + text += L" …"; + } + return text; + } /** * @brief 判断坐标是否命中「弱峰调整」的某条滑块轨道。 @@ -2896,27 +2932,31 @@ namespace jdi void PlotView::EdgeCountsFor(const DataFile& file, const SignalData& signal, const SignalStyle& style, double edgeValue, bool hasRange, double rangeMin, double rangeMax, - std::size_t& rising, std::size_t& falling) const + std::size_t& rising, std::size_t& falling, bool& complete) const { rising = 0; falling = 0; + complete = false; const std::vector& values = signal.values; // ⚠️ 阈值(横向光标幅值)是**屏幕上的值**,因此判定必须基于**画出来的曲线**: // 先按下标映射(水平镜像)取源采样,再套用「调整」变换(系数 → 绝对值 → 垂直镜像 → - // Y 偏移)。只比较原始采样值时,“系数 50”这类信号的原始值域与阈值完全不相交, - // 表现就是“明明是很规整的方波,统计出来却全是 0”(用户报告)。 + // Y 偏移)。只比较原始采样值时,"系数 50" 这类信号的原始值域与阈值完全不相交, + // 表现就是"明明是很规整的方波,统计出来却全是 0"(用户报告)。 const std::size_t count = values.size(); + if (count < 2) + { + complete = true; + return; + } const bool mirrored = style.mirrorHorizontal; const bool transform = (style.gain != 1.0f || style.absolute || style.mirrorVertical || style.offsetY != 0.0f); const std::uint64_t styleKey = EdgeStyleKey(style); - std::size_t begin = 0; - std::size_t end = 0; - const bool indexed = EdgeIndexRange(file, count, hasRange, rangeMin, rangeMax, begin, end); + // 时间单调 ⇒ 统计区间可以用**采样对下标区间**表达(区间不进键,拖动时间光标无需重扫)。 + const bool monotonic = file.timeMonotonic; - // 判定基准:**绘制值**相对阈值的偏差。区间之外的采样返回 NaN(等价于原实现 - // 「区间外采样不参与判定且打断连续」的语义)。 + // 判定基准:**绘制值**相对阈值的偏差(区间之外 / 缺失值返回 NaN = 不参与且打断连续)。 const auto deltaAt = [&values, &file, &style, mirrored, transform, count, hasRange, rangeMin, rangeMax](std::size_t index) -> double { @@ -2946,86 +2986,234 @@ namespace jdi return std::isfinite(drawn) ? drawn : std::numeric_limits::quiet_NaN(); }; - // 按「文件 + 信号 + 样式指纹 + 阈值 + 是否限定区间」定位缓存项(**时间区间不进缓存键**)。 - EdgeCountEntry* entry = nullptr; - for (EdgeCountEntry& candidate : m_edgeCountCache) + // 请求的「采样对」区间 [pairFirst, pairLast](pair k = 采样 k-1 → k,k ≥ 1)。 + // ⚠️ 只有**两个端点都落在区间内**的采样对才计入(非单调时无法用下标表达,由 deltaAt 过滤)。 + std::size_t pairFirst = 1; + std::size_t pairLast = count - 1; + if (hasRange && monotonic) { - if (candidate.file == &file && candidate.signal == &signal && - candidate.styleKey == styleKey && candidate.hasRange == hasRange && - candidate.edgeValue == edgeValue) + std::size_t indexBegin = 0; + std::size_t indexEnd = 0; + EdgeIndexRange(file, count, hasRange, rangeMin, rangeMax, indexBegin, indexEnd); + pairFirst = (indexBegin + 1 < count) ? (indexBegin + 1) : count; + pairLast = (indexEnd > 0) ? (indexEnd - 1) : 0; + if (pairLast < pairFirst) { - entry = &candidate; - break; + complete = true; + return; } } - if (entry == nullptr) + + // ---- 定位 / 推进累积条目 ------------------------------------------------- + // ⚠️ 拖动横向光标(`EDGE_MOVE`)时阈值每帧都变,推进等于纯白做功:此时不推进, + // 直接把上一个完整结果当作临时值(界面用 "…" 标出"还没算完")。 + const bool thresholdDragging = (m_dragMode == PlotDragMode::EDGE_MOVE); + const auto sameSignal = [&file, &signal, styleKey](const EdgeCountEntry& candidate) + { return candidate.file == &file && candidate.signal == &signal && candidate.styleKey == styleKey; }; + const auto sameKey = [&](const EdgeCountEntry& candidate) { - if (m_edgeCountCache.size() >= kEdgeCountCacheLimit) + if (!sameSignal(candidate) || candidate.threshold != edgeValue) { - m_edgeCountCache.erase(m_edgeCountCache.begin()); + return false; } - EdgeCountEntry created; - created.file = &file; - created.signal = &signal; - created.styleKey = styleKey; - created.edgeValue = edgeValue; - created.hasRange = hasRange; - m_edgeCountCache.push_back(created); - entry = &m_edgeCountCache.back(); - } + // 非单调时间轴:区间参与判定,必须一并比较(否则会混用别的区间结果)。 + return monotonic || (candidate.hasRange == hasRange && candidate.rangeMin == rangeMin && + candidate.rangeMax == rangeMax); + }; - // 阈值参与判定,因此仿函数要在累加时一并带入。 - const auto crossingAt = [&deltaAt, edgeValue](std::size_t index) -> double - { return deltaAt(index) - edgeValue; }; - - // 区间完全一致 → 直接命中。 - if (indexed && entry->reusable && entry->begin == begin && entry->end == end) + // 用**下标**而不是指针记录候选(`push_back` 可能重新分配,指针会失效)。 + int entryIndex = -1; + int newestCompleteIndex = -1; + for (std::size_t index = 0; index < m_edgeCountCache.size(); ++index) { - rising = entry->rising; - falling = entry->falling; - return; + const EdgeCountEntry& candidate = m_edgeCountCache[index]; + if (!sameSignal(candidate)) + { + continue; + } + if (entryIndex < 0 && sameKey(candidate)) + { + entryIndex = static_cast(index); + } + if (candidate.complete) + { + newestCompleteIndex = static_cast(index); // 淘汰从 front 开始 ⇒ 越靠后越新 + } + } + EdgeCountEntry* entry = (entryIndex >= 0) ? &m_edgeCountCache[static_cast(entryIndex)] : nullptr; + + if (!thresholdDragging && (entry == nullptr || !entry->complete)) + { + if (entry == nullptr) + { + if (m_edgeCountCache.size() >= kEdgeCountCacheLimit) + { + m_edgeCountCache.erase(m_edgeCountCache.begin()); + } + EdgeCountEntry created; + created.file = &file; + created.signal = &signal; + created.styleKey = styleKey; + created.threshold = edgeValue; + created.hasRange = hasRange && !monotonic; + created.rangeMin = rangeMin; + created.rangeMax = rangeMax; + const std::size_t blocks = (count - 1) / kEdgeBlockSamples + 2; + created.blockRising.assign(blocks, 0); + created.blockFalling.assign(blocks, 0); + m_edgeCountCache.push_back(std::move(created)); + entry = &m_edgeCountCache.back(); + newestCompleteIndex = -1; // 可能已被重新分配/淘汰,不再使用它 + } + + // 扫描预算按 tick 共享:同一帧里本函数会被调用多次(排版 + 绘制 + 多条信号), + // 若每次都各扫一份,单帧总开销就会翻好几倍(实测整段 25.8M 採样 ≈115ms)。 + const std::uint64_t tick = GetTickCount64(); + if (tick != m_edgeScanTick) + { + m_edgeScanTick = tick; + m_edgeScanBudget = kEdgeScanPairsPerTick; + } + if (m_edgeScanBudget > 0 && entry->scannedPair < count) + { + std::size_t scanned = 0; + std::size_t block = (entry->scannedPair - 1) / kEdgeBlockSamples; + std::size_t pair = entry->scannedPair; + const bool fastPath = monotonic && !mirrored && !transform; + while (pair < count && scanned < m_edgeScanBudget) + { + const std::size_t blockFirst = block * kEdgeBlockSamples + 1; + const std::size_t blockLast = std::min(count - 1, (block + 1) * kEdgeBlockSamples); + const std::size_t from = std::max(pair, blockFirst); + const std::size_t to = std::min(blockLast, from + (m_edgeScanBudget - scanned) - 1); + if (to < from) + { + break; + } + std::size_t localRising = 0; + std::size_t localFalling = 0; + if (fastPath) + { + // 紧凑循环:无变换 / 无镜像 / 时间单调 ⇒ 直接比较阈值,NaN 天然不计数。 + // (NaN 与任何数比较都为 false,因此不需要额外的 isfinite 分支。) + double previous = values[from - 1] - edgeValue; + for (std::size_t index = from; index <= to; ++index) + { + const double current = values[index] - edgeValue; + if (previous < 0.0 && current >= 0.0) + { + ++localRising; + } + else if (previous > 0.0 && current <= 0.0) + { + ++localFalling; + } + previous = current; + } + } + else + { + double previous = deltaAt(from - 1) - edgeValue; + for (std::size_t index = from; index <= to; ++index) + { + const double current = deltaAt(index) - edgeValue; + if (std::isfinite(previous) && std::isfinite(current)) + { + if (previous < 0.0 && current >= 0.0) + { + ++localRising; + } + else if (previous > 0.0 && current <= 0.0) + { + ++localFalling; + } + } + previous = current; + } + } + if (block < entry->blockRising.size()) + { + entry->blockRising[block] += localRising; + entry->blockFalling[block] += localFalling; + } + entry->rising += localRising; + entry->falling += localFalling; + scanned += (to - from + 1); + pair = to + 1; + if (to == blockLast) + { + ++block; + } + } + entry->scannedPair = pair; + entry->complete = (pair >= count); + m_edgeScanBudget -= std::min(m_edgeScanBudget, scanned); + } } - // 与上一帧共享一个端点(拖动光标时必然如此)→ 只扫描两端差集并增量加减。 - const bool shareBegin = indexed && entry->reusable && entry->begin == begin; - const bool shareEnd = indexed && entry->reusable && entry->end == end; - const std::size_t oldBegin = entry->begin; - const std::size_t oldEnd = entry->end; - - std::size_t deltaRising = 0; - std::size_t deltaFalling = 0; - if (shareBegin && !shareEnd) + // ---- 汇总请求区间 ------------------------------------------------------- + // 优先用「键完全匹配」的条目(可能仍在推进);没有就用最新的完整条目顶替, + // 这样拖动阈值期间数字保持稳定、算完后自动切换到精确值。 + const EdgeCountEntry* source = (entry != nullptr) + ? entry + : ((newestCompleteIndex >= 0) + ? &m_edgeCountCache[static_cast(newestCompleteIndex)] + : nullptr); + if (source == nullptr) { - // 右端移动:变动的是采样对 k ∈ [min(oldEnd, end), max(oldEnd, end) - 1]。 - const std::size_t low = std::min(oldEnd, end); - const std::size_t high = std::max(oldEnd, end); - AccumulateEdgeCrossings(std::max(1, low), high - 1, crossingAt, deltaRising, - deltaFalling); - rising = (end > oldEnd) ? (entry->rising + deltaRising) : (entry->rising - deltaRising); - falling = (end > oldEnd) ? (entry->falling + deltaFalling) : (entry->falling - deltaFalling); + return; // 尚无任何结果:界面显示"统计中…" } - else if (shareEnd && !shareBegin) + const auto sumRange = [&](const EdgeCountEntry& from, std::size_t first, std::size_t last, + std::size_t& outRising, std::size_t& outFalling) -> bool { - // 左端移动:变动的是采样对 k ∈ [min(oldBegin, begin) + 1, max(oldBegin, begin)]。 - const std::size_t low = std::min(oldBegin, begin); - const std::size_t high = std::max(oldBegin, begin); - AccumulateEdgeCrossings(low + 1, high, crossingAt, deltaRising, deltaFalling); - rising = (begin < oldBegin) ? (entry->rising + deltaRising) : (entry->rising - deltaRising); - falling = (begin < oldBegin) ? (entry->falling + deltaFalling) : (entry->falling - deltaFalling); - } - else - { - // 两端都变了(或首次统计):整段重算。 - const std::size_t pairFirst = (end > begin) ? (begin + 1) : 1; - const std::size_t pairLast = (end > begin) ? (end - 1) : 0; - AccumulateEdgeCrossings(pairFirst, pairLast, crossingAt, rising, falling); - } - - entry->reusable = indexed; - entry->begin = begin; - entry->end = end; - entry->rising = rising; - entry->falling = falling; + outRising = 0; + outFalling = 0; + if (last < first) + { + return true; + } + const std::size_t limit = (from.scannedPair > 0) ? std::min(last, from.scannedPair - 1) : 0; + if (limit >= first) + { + std::size_t block = (first - 1) / kEdgeBlockSamples; + const std::size_t lastBlock = (limit - 1) / kEdgeBlockSamples; + for (; block <= lastBlock; ++block) + { + const std::size_t blockFirst = block * kEdgeBlockSamples + 1; + const std::size_t blockLast = (block + 1) * kEdgeBlockSamples; + const std::size_t low = std::max(first, blockFirst); + const std::size_t high = std::min(limit, blockLast); + const bool wholeBlock = (low == blockFirst) && (high == blockLast); + if (wholeBlock && from.scannedPair > blockLast && block < from.blockRising.size()) + { + outRising += from.blockRising[block]; + outFalling += from.blockFalling[block]; + continue; + } + // 边界块 / 尚未扫完的块:就地精确扫描这一小段(≤ 1 个块)。 + double previous = deltaAt(low - 1) - edgeValue; + for (std::size_t index = low; index <= high; ++index) + { + const double current = deltaAt(index) - edgeValue; + if (std::isfinite(previous) && std::isfinite(current)) + { + if (previous < 0.0 && current >= 0.0) + { + ++outRising; + } + else if (previous > 0.0 && current <= 0.0) + { + ++outFalling; + } + } + previous = current; + } + } + } + return (last < from.scannedPair); + }; + const bool covered = sumRange(*source, pairFirst, pairLast, rising, falling); + complete = covered && sameKey(*source); } float PlotView::EdgePanelWidth(int laneIndex, EdgePanelKind kind) const @@ -3070,6 +3258,7 @@ namespace jdi } std::size_t rising = 0; std::size_t falling = 0; + bool complete = true; if (meanFilter || weakPeak) { continue; @@ -3088,12 +3277,12 @@ namespace jdi else { EdgeCountsFor(*resolved.file, *resolved.data, *resolved.style, lane.edgeValue, - EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling); + EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling, complete); } + // 宽度按实际要显示的文本测量(含未算完时的“统计中…”/省略号), + // 否则推进过程中数字变长会被截断(统计数字必须完整显示)。 const float countWidth = - UiMeasureTextWidth(m_fonts->monoSmall, - L"上升沿 " + std::to_wstring(rising) + L" 下降沿 " + - std::to_wstring(falling)); + UiMeasureTextWidth(m_fonts->monoSmall, EdgeCountsText(rising, falling, complete)); width = std::max(width, kEdgePanelPad * 2.0f * m_scale + std::max(nameWidth, countWidth)); } @@ -4233,12 +4422,12 @@ namespace jdi } } - // 图例中的「GX」编号徽标:悬停时返回**手型** —— 与左侧信号列表里同一个 GX 徽标的 - // 悬停效果完全一致(用户明确要求"参考信号选区的信号名后方的 GX 标签"); - // 真正按下拖拽时再切为上下箭头(见 LANE_REORDER 分支)。 + // 图例中的「GX」编号徽标:**悬停时不改变鼠标形状**(用户明确要求:只在按下左键 + // 拖拽时才显示上下箭头,见 `LANE_REORDER` 分支)。这里保持“不改变”的语义并 + // **提前返回**(不再往下落到光标线/图例判定),与原先的优先级完全一致。 if (LaneBadgeAtPoint(x, y) >= 0) { - return LoadCursorW(nullptr, IDC_HAND); + return nullptr; } // 光标时间读数标签与双光标差值标签:**悬停(未按下)时不改变鼠标形状**。 @@ -11665,10 +11854,11 @@ namespace jdi std::size_t rising = 0; std::size_t falling = 0; + bool complete = true; EdgeCountsFor(*resolved.file, *resolved.data, *resolved.style, tabLane.edgeValue, - EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling); - const std::wstring counts = L"上升沿 " + std::to_wstring(rising) + L" 下降沿 " + - std::to_wstring(falling); + EdgeRangeActive(), EdgeRangeMin(), EdgeRangeMax(), rising, falling, + complete); + const std::wstring counts = EdgeCountsText(rising, falling, complete); UiText(g, counts, m_fonts->monoSmall, gd::RectF(panel.X + pad, rowY + rowHeight, textWidth, rowHeight), m_theme->textMuted, TextAlign::LEFT); diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index 6889d47..ad39acf 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -998,6 +998,127 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( --- +### 9.22 新标签页默认折起分组 + GX 徽标悬停不改光标 + 标题栏 RAM 口径 + 边沿计数改为预算推进(本轮) + +> 动机:用户报告 4 个交互/数值问题 + 1 个性能问题(本机起源设备)。全部离屏验证。 + +#### 一、新建标签页的信号分组**默认折起** + +- `Application::NewTab(bool collapseGroups)`(新增形参):**启动时的首个标签页传 false**(加载文件后可直接勾选信号), + **用户新建的标签页(Ctrl+T / 标签栏 ⊕ / 标签栏右键「新建标签页」)传 true**。 +- `TabConfig::SetGroupsCollapsedByDefault(bool)` + `TabConfig::SetAllCollapsed(bool)`(新增); + `SyncToDataSet` 里 `m_collapsed.resize(fileCount, m_groupsCollapsedByDefault)`(原来固定 `false`)—— + ⚠️ 只影响**新出现的**分组(之后新加载的文件),已存在的分组保持用户当前选择。 + `ToggleCollapsed` 里为越界下标补建状态时同样用该默认值。 + +#### 二、网格 GX 徽标:悬停**不改变**鼠标形状,只有拖拽才是上下箭头 + +- `PlotView::QueryCursor` 里 GX 徽标分支由 `IDC_HAND` 改为 **`return nullptr`**(= 保持箭头)。 + 位置仍在原位(先于光标线/图例判定返回),优先级语义不变; + 拖拽中(`LANE_REORDER`)仍是 `IDC_SIZENS`。 +- ⚠️ 注意不要"顺手"把 `QueryCursor` 的徽标分支改成"落到后面继续判定":那会让经过徽标的光标线 + 抢到 `IDC_SIZEWE`(原实现用提前 return 就是为了这个优先级)。 + +#### 三、标题栏 RAM 口径:总工作集 → **私有工作集**(任务管理器"内存"列) + +- **症状**:加载大文件再关闭分组后,标题栏还显示几十/上百 MB,而任务管理器只有几 MB。 +- **根因**:原实现取 `PROCESS_MEMORY_COUNTERS::WorkingSetSize`(**总工作集**)—— + 它把映射进来的 DLL/共享页面、以及已释放但尚未被系统回收的驻留页面都算进去; + 任务管理器"内存"列的口径是 **私有工作集**(`PROCESS_MEMORY_COUNTERS_EX2::PrivateWorkingSetSize`)。 + 实测(本机离屏探针):加载 `Problem_Data.csv` 后 total-WS **117.55 MiB** / priv-WS **109.96 MiB** / + commit 138.28 MiB;关闭分组后 total-WS **15.44** / priv-WS **7.62** / commit **8.27** MiB。 +- **修法**:`ProcessWorkingSetBytes()` → **`ProcessPrivateWorkingSetBytes()`**,用 + `PROCESS_MEMORY_COUNTERS_EX2`(MinGW 的 psapi.h 没有这个类型 → **在 .cpp 里就地声明**, + 布局与文档一致),失败(老系统)依次退回 `PrivateUsage`(私有提交)→ `WorkingSetSize`。 +- 验证:harness 新增 `[AS]` —— 标题文本必须包含 `FormatByteSize(privWS)`,且在两者格式不同时 + **不得**包含 `FormatByteSize(totalWS)`(实测 priv 158.29 MB / total 195.53 MB)。 + +#### 四、边沿计数(`EdgeCountsFor`):逐块累积 + 每 tick 预算推进(**修掉"拖横向光标卡死"**) + +- **量化(探针 `.preflight/probe`,合成方波)**:改前 **6M 采样 = 26.8 ms / 次阈值变化** + → 折算 **25.8M 采样 ≈ 115 ms/条**。横向光标拖动时阈值每帧都变 ⇒ 单条信号每帧 115ms, + **5 条信号同一网格 = 575ms/帧**(用户报告"开启即卡顿、之后光标几乎拖不动"完全吻合)。 +- **新模型**(与 `RenderPendingLaneRebuilds(1)` 同一套"宁可晚一点、绝不卡住前台"的思路): + * 采样对 `k`(采样 `k-1 → k`)按 `kEdgeBlockSamples = 8192` 分块,每块各存上升/下降沿数; + * `EdgeCountEntry::scannedPair` = 下一个待扫描的采样对,`[1, scannedPair)` 已扫完; + * **查询区间**:内部整块用块和相加,两端边界块就地精确扫描(≤ 2 块,代价固定)⇒ + 拖动**时间**光标的单帧代价与区间长度无关(区间**不进键**,单调时间轴下无需重扫); + 非单调时间轴无法用下标区间表达 ⇒ 区间进键(进键与否由 `file.timeMonotonic` 决定); + * **每 tick 预算** `kEdgeScanPairsPerTick = 1200000`(`GetTickCount64` 分桶,同帧多次调用共享), + 预算用完即返回当前部分值;`complete` 出参告诉界面"还没算完"; + * **拖动横向光标(`EDGE_MOVE`)期间不推进**(阈值每帧都变,推进纯属白做功)→ 直接返回 + "最新的完整条目"的结果(数字稳定)+ `complete = false`; + * 快路径:`单调 && 无镜像 && 无调整变换` 时用**紧凑无分支循环**直接比较 `values[k]` 与阈值 + (NaN 与任何数比较都是 false ⇒ 天然满足"NaN 不参与且打断连续"的语义,无需 isfinite 分支)。 +- **界面**:`EdgeCountsText(rising, falling, complete)` 统一生成文本 —— 完全未算出时显示 + **`统计中…`**,部分结果追加 **` …`**;`EdgePanelWidth` 与绘制都改用它(宽度按实际文本测量, + 避免推进过程中数字变长被截断)。 +- **实测(改后)**:6M 采样首次调用 **1.7 ms**(部分值),5 帧内扫完且与参考实现**逐位一致**; + **单次调用最坏 1.9 ms**;拖动中连查 10 个阈值 **合计 0.11 ms**(O(1)/帧); + 区间查询在扫描完成后**一次调用即精确**。 +- ⚠️ 本轮**只改了普通「边沿计数」**;`EdgeCenterCountsFor` 仍沿用原增量实现 + (它的曲线窗口本来就被 `CurveSpanFor` 限制,不是本次用户报告的场景)。 +- ⚠️ harness 的 `EdgeCountsFor` 调用**必须循环到 `complete`**(模拟逐帧推进), + 否则拿到的是部分值。 +- ⚠️ **harness 抖动(菜单点击类用例)**:留在打开的弹出菜单/内联编辑态里的第一次点击会 + 被吞掉("输入框外的第一次点击 = 提交编辑"是产品设计)。本轮给菜单类用例加了 + `CloseAllMenus(app)` 前置卫生与**允许补点 1~2 次**的逻辑(`[AL]` 的「系数/偏移」按钮、 + `[AR]` 的调整按钮),显著降低抖动;但 `[AP]` 的 reset 图标点击仍**偶发**失败(连跑 5 次约 1 次), + 重跑即好 —— 属既有 harness 抖动,不是产品缺陷。 + +#### 五、⚠️ 内存与性能排查报告(用户要求;第 4 项**只排查未改动**) + +##### 5.1 内存管理审计(对应"标题栏数字不准 / 怀疑野指针") + +- **结论 1:内存没有泄漏,且能完整归还。** 关闭分组(`RemoveFile`)路径实测 + `117.55 → 15.44 MiB`(总工作集)/`109.96 → 7.62`(私有工作集)/`138.28 → 8.27`(私有提交)。 + 该路径会 `PauseDerivedAsync()`(清 `m_curveEntries`/`m_curveJobs`/`m_curvePins`) + → `InvalidateDerivedCaches()`(清折线/边沿/均值/弱峰/场景缓存);`DataSet::RemoveFile` 用 + "前移 + pop_back + shrink_to_fit" 立即归还采样缓冲。 +- **结论 2:用户看到的差异来自"口径",不是"错误"。** 见上文第三节 —— 标题原来显示总工作集。 +- **结论 3(⚠️ 真实隐患,本轮未修):全仓没有任何 `try`/`catch`。** 所有重分配 + (`std::vector::assign/resize`)失败时抛 `std::bad_alloc`;而重计算都跑在 + `std::thread` 上(`PlotView::CurveWorkerLoop`、`Application::RunAsyncLoad`, + 以及并行图层 / 并行重建),**异常逸出线程入口 ⇒ `std::terminate()` = 进程直接终止**。 + 即"内存不足"在本项目当前实现里是**崩溃**而不是"报错并放弃本次操作"。 + +##### 5.2 弱峰调整 "迭代次数 1→10" 内存暴涨(4.4GB)——原因 / 改进方案 / 低内存行为 + +- **实测(探针,3M 采样合成方波,单次 `ComputeWeakPeakAdjust`)**: + 单次计算的工作缓冲峰值 ≈ **103 MiB / 3M 采样 ≈ 34 B/采样**; + **4 条并发计算峰值 +412 MiB**(即 4 × 103 MiB)。按 25.8M 采样线性外推 ⇒ + **单次 ≈ 0.9 GB,4 线程 ≈ 3.5 GB**;再加数据本体 1.03 GB 与曲线缓存预算(≤768 MB) + ⇒ **峰值 4~5 GB**,与用户观测的 4.4 GB 吻合。 +- **构成**(每 1 个采样):`current`+`next` 两个 `float` 工作缓冲 = 8 B; + 调用方的 `out`(float)= 4 B;**极值表 `PeakExtremum`(`size_t index` + `double value` + `int type` + = 24 B/极值)**;位移表 `shift`(double)= 8 B/极值。 + 编码器方波是**极值密集**信号(每个跳变都是极值)⇒ 极值表可达采样数量级 ⇒ **24 B/采样成为主导项**。 +- **为什么"拖到 10"会同时拉起 4 份**:`ScheduleCurveJob` 对同 (信号, 曲线种类) 只保留**一个待算**任务, + 但**正在计算的任务无法取消**;工作线程池 = `clamp(核数-1, 1, 4)`,参数一路变化时会连续留下 + 若干个"算到一半的旧参数任务",每个都持有自己的 `thread_local` 大缓冲。 + (`CurveSpanFor` 的 `kHugeSignalPreviewSamples` 只在**拖拽期间**不再排队,松手后仍会算一次最终参数。) +- **改进方案(建议,未实施)**: + 1. **按信号规模限制并发**:超大信号(> `kHugeSignalPreviewSamples`)时派生计算**串行化**(1 线程) + ⇒ 峰值直接除以 4; + 2. **缩小极值表**:`{uint32 index; float value; int8 type}` = 12 B(减半),或**分块流式处理**, + 不落地整张极值表; + 3. **抢占/取消**:任务在每阶之间检查"本参数是否已被更新的参数取代"(比对 generation/最新键), + 废弃任务尽早退出,避免为过时参数白算 10 阶; + 4. **低内存自适应**:跑之前用 `GlobalMemoryStatusEx` 看可用物理内存,不足时降档 + (减少并发 / 拒绝本次重建 / 明确提示"内存不足"); + 5. **异常保护**(见 5.1 结论 3):给所有线程入口加 `try/catch(std::bad_alloc)`, + 把"崩溃"变成"提示 + 跳过本次计算"。 +- **低内存场景(1 GB 剩余)现有机制够不够?** + * **有的**:`ReuseBufferScope`(容量封顶,超大缓冲用完即释放)、 + `TrimDerivedCurveDataIfIdle`(关掉预览即 `PauseDerivedAsync` 归还内存)、 + `kHugeSignalPreviewSamples`(拖拽期不排队)、曲线缓存总字节预算 `kCurveSpanCacheMaxBytes`。 + * **没有的**:**任何"峰值上限"与"优雅降级"**。上面 4 项都只解决"算完/关掉之后归还", + 不解决"算的时候需要 4 GB"。 + * 因此 1 GB 剩余内存时的行为是:先大量换页(全系统卡顿)→ 提交失败则 `bad_alloc` + → **无线程级 catch ⇒ 进程终止**(崩溃)。用户这台机器内存足够所以只表现为"内存暴涨"。 + +--- + ## 10. 变更日志(本文件维护记录) | 日期 | 设备/模型 | 摘要 | @@ -1031,6 +1152,7 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( | 2026-10-13 | DeepSeek-V4.1-Flash(**另一台设备**) | **深放大中键拖拽卡顿(根因=GDI+ 抗锯齿"满幅锯齿"病态)+ 横向补条裁剪 + 并行网格图层(B/C,详见 §9.18)**。① **B 拆解**:该视界(`[10.2719,10.2831]s`)下 `RollWideBuffer` 补条重绘 **13.08ms**,其余 ≤0.8ms —— 瓶颈全在补条重绘。② **补条裁剪**:横向补条只覆盖窄竖带,却按整条宽窗口构建/描边整段折线;改为按条带 x 范围裁剪采样区间(旁路折线缓存),`RollWideBuffer` **13.08ms → 1.73ms**,拖拽整帧 13~24ms → ~3ms,且**逐像素 0 差异**。③ **病态根因**:拖拽超出预渲染余量时走整块重画,**整宽单网格渲染 655ms** —— 同 4481 点 `DrawLines` 实测 **AA 694ms / 无 AA 29ms(23 倍)**(编码器方波在 1~2 采样/像素形成每列整幅跳变的锯齿,原实现此时既不走包络也不关 AA);修法:**包络带阈值从 2 采样/像素降到 1**,并扩展"梳状(关 AA)"判据(多数列列内跨幅 >0.5×网格高 / 相邻列整幅跳变)——**几何一字未改**;整宽渲染 **655→47.7ms**、补条 6.9→2.4ms、拖拽 200 帧 **avg 4.5 / max 5.8ms / 0 帧 >8ms**。④ **C(用户解除多线程限制)**:实现 `RebuildLaneLayersParallel` —— 每线程一个**隔离 `PlotView`**(自有 `m_workerTab`/`m_area`/`m_plotRect`/`m_lanes`)渲染不同网格,主线程 join 后按真实下标重算 key;并行 vs 顺序**逐像素 0 差异**;⚠️ 踩坑:`BuildLaneVisuals` 在 `m_tab==nullptr` 时清空 `m_lanes`,故**必须给 worker 一个非空的自有 tab**。**诚实结论:并行仅 1.03x**(同机 4 线程 CPU 真并行 3.98x → GDI+ 光栅化被串行化),故**并行对光栅化无收益**,仍保持"视界停后每帧补 1 个网格"。验证:`dragperf` mode 0/1/3/5/6/8/9、`pancheck` 全 `dy=0`、`zoomcheck` 无回归、`make clean && make all` 零警告。 | | 2026-10-14 | DeepSeek-V4.1-Flash | **装饰层改为带 alpha 的独立图层(消除 GX/图例外围"直角白边")+ 图例圆角托底与悬停/选中配色统一 + 预览曲线内存释放(详见 §9.20)**。① **白边根因**:装饰层原为"从后备缓冲区抓取一块**不透明矩形**",圆角之外(属于波形)的像素被一起烘进缓存,为让其跨视界可复用又在圆角外铺了直角不透明底色 ⇒ 密集波形上就是一圈直角白边。**修法**:`m_decorDc` 改 **32bpp DIBSection**、新增 `RenderDecorCache()` 把装饰**单独绘制**进全透明位图(不再抓取)、`BlitDecorCache()` 改用 **`AlphaBlend`**(`AC_SRC_ALPHA`,`GetProcAddress(gdi32!GdiAlphaBlend)` 解析、不新增链接依赖)⇒ 圆角外透出**当前**波形;并做 **alpha 修补**(GDI 文本会把写入像素的 alpha 清 0,不修补文字会变透明洞);删除全部外扩直角底色。② **图例**:逐条目圆角托底(与 GX 徽标同风格),**选中态 = 悬停态配色**(`accentSoft`+`accent`+`textStrong`),悬停在不透明托底上重绘色块/文字并保留曲线光晕;`LegendHit` 新增 `radius`;补齐「+N」/「(无可见信号)」提示的装饰矩形。③ **内存**:`thread_local` 工作缓冲**保留容量封顶**(`kReuseBufferMaxElements` + `ReuseBufferScope`,超大计算用完即释放)+ 关闭最后一个预览功能时 `TrimDerivedCurveDataIfIdle()`(`PauseDerivedAsync` 让线程退出、thread_local 析构归还内存)—— 实测 1792MB → **关闭后 1203MB**(修复前不下降)。④ 性能:`BlitDecorCache` **0.19ms/帧**、`RenderDecorCache` 2.4ms(仅内容变化时);`zoomcheck` mode 13 收敛帧与整幅重绘**逐像素 0 差异**(此前 3503)。验证:离屏 `verify.exe` **49/49 通过**、`make release` 零警告。 | | 2026-10-14 | DeepSeek-V4.1-Flash | **修复远端「调整」功能引入的性能回归 + 根治首帧卡顿 + 图例交互/托底重做(详见 §9.19)**。① **8 倍回归**:远端把 `DataSet::Style(ref)` 写进了 `AutoRangeLaneToView` 的逐采样循环(亿级解析调用)→ 工具栏「Y 轴自适应」在全视图下 **1200ms**(旧提交 147ms);修法:样式指针提循环外 + **整段自适应取已缓存 min/max(O(1),结果逐位一致)** + 无变换走紧凑循环 → **0.02ms**。② **首帧卡顿根治**:加载后宽基底图层从未分配,用户第一次平移/缩放时 `Paint` 在那一帧**同步重建全部 5 个网格图层**(首帧 **64.3 → 13.0ms**);修法:新增 `WarmLaneLayersStep()`,在**命中静态场景缓存**(视界未变、非拖拽)时用预算机制**每帧至多补 1 个网格**,把整批重建摊到静止帧。③ **派生信号把「调整」烘进数据**(不再继承样式指纹);⚠️ 当时写的“仅 `offsetTime` 因共用文件时间轴仍需继承”**已被 §9.21 推翻**(现在 `offsetTime` 也按 `time[i]-offsetTime` 重采样烘进采样值)。④ **GX 拖拽让位判据改为鼠标指针**(鼠标越过相邻网格中心水平线即让位),并给 GX 徽标加悬停高亮(叠加层 + `IDC_HAND`,与信号列表 GX 徽标一致)。⑤ **图例命中矩形在装饰缓存路径下必须重建**(原先命中缓存时提前 return → `m_legendHits` 被清空且不重建 → 中键平移后图例悬停/点选全失效)。⑥ **每个图例条目加圆角托底 + 装饰矩形改为逐块 + 托底不透明铺满外接矩形** → 消除「GX/图例周边旧波形像素残留」(逐像素验证装饰矩形内 **0 差异**);悬停高亮为不透明托底并**重绘色块与文字**(否则内容被盖住)。验证:离屏 Application harness **40/40 通过**,`zoomcheck` mode 20–26 量化,`make release` 零警告。 | +| 2026-10-14 | DeepSeek-V4.1-Flash(**起源设备**) | **新标签页分组默认折起 + GX 徽标悬停不改光标 + 标题栏 RAM 改用「私有工作集」+ 边沿计数改为「逐块累积 + 每 tick 预算推进」(详见 §9.22)**。① `NewTab(bool collapseGroups)`(首个标签页 false、用户新建 true)+ `TabConfig::SetGroupsCollapsedByDefault/SetAllCollapsed`;`SyncToDataSet` 用该默认值初始化**新出现的**分组。② `QueryCursor` 的 GX 徽标分支由 `IDC_HAND` 改为 `nullptr`(悬停不改形状,仅拖拽时 `IDC_SIZENS`)。③ **RAM 口径修正**:原取 `WorkingSetSize`(总工作集,含映射 DLL/共享页与已释放未回收的驻留页)→ 改为 `PROCESS_MEMORY_COUNTERS_EX2::PrivateWorkingSetSize`(与任务管理器「内存」列一致;失败退回 `PrivateUsage` → `WorkingSetSize`)。实测加载后 117.55/109.96 MiB、关闭分组后 15.44/7.62 MiB —— **无泄漏,内存完整归还**。④ **边沿计数性能**:改前 25.8M 采样 ≈**115ms/条/帧**(5 条同网格 = 575ms/帧,用户报告"开启即卡顿、之后拖不动");改为按 `kEdgeBlockSamples=8192` 分块累积、每 tick 预算 `kEdgeScanPairsPerTick=1.2M`、区间用块和相加(区间不进键)、拖动横向光标期间不推进并显示上一个完整值 + `complete=false`(`EdgeCountsText` 显示"统计中…/…");实测首帧 1.7ms、单次最坏 1.9ms、拖动中 10 次查询 0.11ms、最终结果与参考实现逐位一致。⑤ **内存排查报告**(用户要求,第 4 项只排查未改动):弱峰调整工作缓冲实测 **≈34 B/采样**(极值表 `PeakExtremum` 24 B/极值为主导),**4 条工作线程并发 +412 MiB / 3M 采样** ⇒ 25.8M 采样峰值 ≈**3.5GB**(+数据 1.03GB +曲线缓存 ≤768MB ⇒ 4~5GB,与用户 4.4GB 观测吻合);**全仓无 `try`/`catch`** ⇒ 低内存时 `bad_alloc` 逸出线程入口 ⇒ `std::terminate()` 崩溃(无优雅降级机制)。验证:离屏 `uitest` **738/738 通过**(新增 `[AR]` 边沿计数预算/精确性/拖动期 O(1)、`[AS]` 标题栏 RAM 口径),`make release` 零警告。 | | 2026-10-14 | DeepSeek-V4.1-Flash(**起源设备**) | **边沿计数改用「绘制值」+ 派生信号烘入时间偏移 + 图例托底统一为 GX 徽标款 + 调整二级面板向上展开(详见 §9.21)**。① **边沿计数恒为 0**:`EdgeCountsFor`/`EdgeCenterCountsFor` 只比原始采样值,而阈值是屏幕上的值 → 「系数 50」的信号统计恒为 0;修法:判定值改为 `ApplyDrawTransform(style, values[SourceIndexFor(...)])`(中心轴两侧同套变换;水平镜像时曲线窗口也要先映射下标);缓存新增 **`styleKey`**(否则改完「调整」仍命中旧计数)。② **区间边界点被误计**:`EdgeIndexRange` 左端“多取一个采样点”的外扩**已删除**,收紧为“**两个端点都在区间内**才计入”(光标恰落在越过阈值的点 B 上时 B 不再被算作上升沿)。③ **`offsetTime` 也烘进派生采样值**(**推翻 §9.19 的“唯一例外”**):按 `time[i]-offsetTime` 取最近采样点重采样(单调时间轴用只前进的游标,O(n);越界置 NaN),新信号样式**全默认**;非单调时间轴退回到把 `offsetTime` 带在样式上。④ **图例托底 = GX 徽标同款**(同高 / 圆角 = 高度一半 / 同配色 / 水平内边距 = 胶囊半径),文本改用新增字体 **`uiTiny`(10 逻辑 px)**;`kLegendPlateRadius` 已删。⑤ **`PopupMenu::Open(..., expandUp=true)`**(锤点为**左下角**、向上展开,`m_buildExpandUp` 供 `RebuildLayout` 复用)+ 「系数/偏移」「镜像」面板锤在按钮左上角再上移 `kAdjustMenuGap`。验证:`.preflight/uitest` 新增 `[AR]`(五组断言全部与**独立参考实现**逐位比对,含 A/B:“按原始采样值算会得到 7/6 个边沿”);**并同步修正 harness 里两处旧规则断言**(`[AP]` 派生信号继承 → 默认样式 + 烘值;`[AN]` 图例带无托底 → 装饰矩形逐块且暴露列可见底层内容);合计 **722/722 通过**,`make release` 零警告。 | --- @@ -1110,6 +1232,29 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( 25. ⚠️ **`PopupMenu::Open(..., expandUp = true)`** 表示"锚点是菜单**左下角**、向**上**展开"。 给按钮挂"二级面板"时用它(并把锚点设在按钮左上角再上移 `kAdjustMenuGap`); `RebuildLayout()` **必须复用 `m_buildExpandUp`**,否则数值回写触发的重排会让面板跳回向下展开。 +26. ⚠️ **标题栏 RAM = "私有工作集"**(`PROCESS_MEMORY_COUNTERS_EX2::PrivateWorkingSetSize`, + 与任务管理器「内存」列同口径),**不是** `WorkingSetSize`(总工作集还包括映射的 DLL/共享页面与 + 已释放但未被回收的驻留页面,会明显偏大)。EX2 失败时退回 `PrivateUsage` → `WorkingSetSize`。 +27. ⚠️ **千万级信号上任何"按阈值判定"的统计都不允许每帧重扫整段**: + `EdgeCountsFor` 已改为「逐块累积(`kEdgeBlockSamples`)+ 每 tick 预算推进(`kEdgeScanPairsPerTick`)」; + 拖动横向光标(`EDGE_MOVE`)期间**不推进**(只返回上一个完整值 + `complete=false`), + 界面用 `EdgeCountsText` 显示"统计中… / …"。实测:改前 25.8M 采样 ≈115ms/条/帧(5 条同网格 = 575ms/帧), + 改后单次调用 ≤1.9ms、拖动中 10 次查询合计 0.11ms。 + ⚠️ 新增同类统计时**必须**沿用这个模型(先量化单次全扫代价,再决定分块与预算)。 +28. ⚠️ **全仓没有 `try`/`catch`**:所有重分配失败抛 `std::bad_alloc`,而重计算都在 `std::thread` + (`CurveWorkerLoop` / `RunAsyncLoad` / 并行图层)里 —— 异常逸出线程入口 ⇒ `std::terminate()` ⇒ **进程终止**。 + 低内存下这是"崩溃"而不是"降级"。要改就从线程入口加 `catch` 开始。 +29. ⚠️ **弱峰调整的工作缓冲 ≈ 34 B/采样(峰值)**,主导项是极值表(`PeakExtremum` = 24 B/极值, + 编码器方波极值密集 ⇒ 接近 24 B/采样);而工作线程池最多 **4** 条并发 ⇒ + 25.8M 采样时单次 ≈0.9GB、峰值 ≈3.5GB(+数据 1.03GB +曲线缓存 ≤768MB ⇒ **4~5GB**)。 + 已测:3M 采样单次 ≈103MiB、4 并发 +412MiB。若日后要"限制峰值",优先: + ①超大信号串行化 ②极值表字段瘦身/分块流式 ③废弃任务尽早退出 ④`GlobalMemoryStatusEx` 自适应降档。 +30. ⚠️ **新建标签页的信号分组默认折起**(`NewTab(true)`;启动首个标签页 `NewTab(false)` 保持展开); + `SyncToDataSet` 用 `TabConfig::SetGroupsCollapsedByDefault` 的值初始化**新出现的**分组, + 不得覆盖已存在分组的用户选择。 + ⚠️ 新增/修改 `NewTab` 调用点时别忘了带上这个形参。 +31. ⚠️ **网格 GX 徽标悬停不改鼠标形状**(`QueryCursor` 在徽标上返回 `nullptr`,且**提前 return** + 以免被光标线/图例判定抢走);只有拖拽中(`LANE_REORDER`)才是 `IDC_SIZENS`。 ### 11.3 离屏验证方法学(本机偏好,强烈建议沿用) diff --git a/Utilities/Inc/DataModel.hpp b/Utilities/Inc/DataModel.hpp index e725517..79dbac5 100644 --- a/Utilities/Inc/DataModel.hpp +++ b/Utilities/Inc/DataModel.hpp @@ -1045,6 +1045,25 @@ namespace jdi */ void ToggleCollapsed(int fileIndex); + /** + * @brief 设置「新出现的文件分组是否默认折起」。 + * + * 只影响**之后**新出现的分组(例如新加载的文件);已存在的分组保持用户当前的选择。 + * 用户新建的标签页会把它设为 true,使信号列表默认紧凑。 + * + * @param [IN] collapsed 是否默认折起。 + */ + void SetGroupsCollapsedByDefault(bool collapsed) { m_groupsCollapsedByDefault = collapsed; } + + /** + * @brief 把当前所有文件分组设为折叠或展开。 + * @param [IN] collapsed true = 全部折起,false = 全部展开。 + */ + void SetAllCollapsed(bool collapsed) + { + std::fill(m_collapsed.begin(), m_collapsed.end(), collapsed); + } + /** * @brief 计算某网格的 Y 轴显示范围。 * @param [IN] dataSet 数据集合。 @@ -1100,6 +1119,13 @@ namespace jdi std::wstring m_name; /**< 标签页名称。 */ std::vector> m_visible; /**< 信号可见性,按 [文件][信号] 索引(本页独立)。 */ std::vector m_collapsed; /**< 文件分组折叠状态,按文件索引。 */ + /** + * @brief 新出现的(首次见到的)文件分组是否默认折起。 + * + * 用户新建的标签页把它设为 true(信号列表默认紧凑);启动时的首个标签页保持 false, + * 这样加载完文件就能直接勾选信号。 + */ + bool m_groupsCollapsedByDefault = false; std::vector m_lanes; /**< 网格划分。 */ LaneMode m_laneMode = LaneMode::PER_SIGNAL; /**< 网格划分策略。 */ double m_viewMin = 0.0; /**< 视图左边界时间。 */ diff --git a/Utilities/Src/DataModel.cpp b/Utilities/Src/DataModel.cpp index a303f2a..8d47427 100644 --- a/Utilities/Src/DataModel.cpp +++ b/Utilities/Src/DataModel.cpp @@ -1731,7 +1731,8 @@ namespace jdi { const std::size_t fileCount = dataSet.FileCount(); m_visible.resize(fileCount); - m_collapsed.resize(fileCount, false); + // ⚠️ 只对**新出现的**分组应用默认折叠状态:已存在的分组保持用户当前的展开/折叠选择。 + m_collapsed.resize(fileCount, m_groupsCollapsedByDefault); for (std::size_t fileIndex = 0; fileIndex < fileCount; ++fileIndex) { @@ -2331,7 +2332,7 @@ namespace jdi } if (static_cast(fileIndex) >= m_collapsed.size()) { - m_collapsed.resize(static_cast(fileIndex) + 1, false); + m_collapsed.resize(static_cast(fileIndex) + 1, m_groupsCollapsedByDefault); } m_collapsed[static_cast(fileIndex)] = !m_collapsed[static_cast(fileIndex)]; }