Compare commits

..
3 Commits
Author SHA1 Message Date
DeepSeek V4.1 Flash aca84df0ac 深度修复实验性功能预览曲线导致的交互卡顿(平移/拖滑块/缩放/改样式不跟手)
根因:派生预览曲线按可见采样窗口计算,代价正比于窗口采样数(弱波增强整段 2576 万采样单条约 368ms);旧策略对超过 4M 采样的窗口不缓存,导致大跨度下每帧(含悬停重绘)重算整段曲线;拖滑块每帧改参数即每帧重算。实测弱波增强整段拖滑块单帧约 2303ms,2.5s 跨度约 89ms。

修复:1) 大窗口也进缓存并按总字节预算淘汰(静止重绘 2247ms-0.25ms);2) 交互期间对超出预算的大窗口延迟重算、复用旧曲线(拖滑块 2303ms-18ms),小窗口仍实时重算;3) 计算缓冲改 thread_local 复用容量。仅改何时计算与是否缓存,几何与取值算法不变,逐像素比对 0 差异。

缩放等级差异:整段放大时窗口为整条信号(千万级)每帧重算故最卡;极小跨度窗口仅几千采样故不卡;中间跨度几十毫秒亦卡。
2026-10-11 11:32:58 +08:00
DeepSeek V4.1 Flash 2f7a6bc2d2 修复右键网格菜单后悬停提示残留;修复实验性功能预览曲线导致的平移/拖光标卡顿
1) 右键弹出网格菜单前清空绘图区悬停状态(并让 OnMouseLeave 一并清 m_hoverLegend),菜单打开后信号光晕与「可拖拽」提示气泡不再残留。
2) 时间光标处派生取值胶囊原先把请求窗口取成「可见区间 ∪ 光标下标」的并集:光标在视界外时窗口达上千万采样,超缓存上限后每帧重算整段多次迭代滑动平均(实测单帧 300ms+),并随平移/拖滑块/拖光标/改色一起变卡。改为只请求光标所在采样点的小窗口,取值依赖由 CurveSpanFor 保护带覆盖,取值逐位一致、显示效果零变化。
离屏实测 VERY_BIG_DATA:均值滤波平移 326ms→16~23ms,中心轴边沿计数平移 300ms→20ms。
2026-10-11 03:47:43 +08:00
DeepSeek V4.1 Flash 2322e76d8c 修复二级菜单移向途中被误关闭;新增项目记忆文档 agent_mem.md
网格右键菜单「实验性功能」展开二级菜单后,鼠标从父项移向子菜单途中经过内边距窄带会被立即收起。改为子菜单展开期间只要指针仍在主菜单∪子菜单范围内且未落到其它可点击条目即保持展开并高亮父项。
2026-10-11 02:59:41 +08:00
6 changed files with 381 additions and 34 deletions

No files matched your search

@@ -2570,6 +2570,10 @@ namespace jdi
const int laneIndex = m_plot.LaneIndexAt(x, y); const int laneIndex = m_plot.LaneIndexAt(x, y);
if (laneIndex >= 0) if (laneIndex >= 0)
{ {
// 菜单打开后鼠标移动被菜单独占,绘图区不再收到移动事件;
// 若不先清掉悬停状态,右键前停留在曲线上的「可拖拽光晕」与提示气泡
// 会一直挂在原地(用户报告的"进入网格菜单后高亮/文本提示不消失")。
m_plot.OnMouseLeave();
ShowPlotContextMenu(laneIndex, x, y); ShowPlotContextMenu(laneIndex, x, y);
InvalidateRect(hwnd, nullptr, FALSE); InvalidateRect(hwnd, nullptr, FALSE);
} }
+6 -1
View File
@@ -1264,12 +1264,17 @@ namespace jdi
* @param [IN] begin 需要的采样区间起点(含)。 * @param [IN] begin 需要的采样区间起点(含)。
* @param [IN] end 需要的采样区间终点(不含)。 * @param [IN] end 需要的采样区间终点(不含)。
* @param [OUT] spanBegin 返回数组对应的首个采样下标。 * @param [OUT] spanBegin 返回数组对应的首个采样下标。
* @param [IN] allowInteractiveDefer 交互期间是否允许「延迟重算」:窗口过大时复用已算好的
* 旧曲线,避免在拖动滑块/移动视界时同步重算整条曲线(见
* `kDerivedInteractiveComputeBudget`)。仅供**预览绘制**传 true;
* 需要精确取值的统计(中心轴边沿计数)必须传 false。
* @return 曲线数组指针(覆盖 `[spanBegin, spanBegin + size())`);无法计算时返回 nullptr。 * @return 曲线数组指针(覆盖 `[spanBegin, spanBegin + size())`);无法计算时返回 nullptr。
*/ */
const std::vector<float>* CurveSpanFor(DerivedCurveKind kind, const SignalData& signal, const std::vector<float>* CurveSpanFor(DerivedCurveKind kind, const SignalData& signal,
int points, int iterations, int levelPercent, int points, int iterations, int levelPercent,
std::size_t begin, std::size_t end, std::size_t begin, std::size_t end,
std::size_t& spanBegin) const; std::size_t& spanBegin,
bool allowInteractiveDefer = false) const;
/** /**
* @brief 计算当前视图对应的采样下标区间(时间单调时二分定位,否则为整段)。 * @brief 计算当前视图对应的采样下标区间(时间单调时二分定位,否则为整段)。
+102 -22
View File
@@ -10,8 +10,6 @@
namespace jdi namespace jdi
{ {
namespace namespace
{ {
const int kTimeTickTarget = 10; /**< 时间轴期望刻度数。 */ const int kTimeTickTarget = 10; /**< 时间轴期望刻度数。 */
@@ -82,16 +80,34 @@ namespace jdi
* 上限避免在完全缩小时把保护带放大到整条信号(那就退化成重算整条曲线)。 * 上限避免在完全缩小时把保护带放大到整条信号(那就退化成重算整条曲线)。
*/ */
const std::size_t kDerivedCurveGuardMax = 65536; const std::size_t kDerivedCurveGuardMax = 65536;
/** 区间窗口缓存条目上限(超出时整体清空;它只是加速手段)。 */ /** 区间窗口缓存条目上限(超出时淘汰最早的条目;它只是加速手段)。 */
const std::size_t kCurveSpanCacheLimit = 32; const std::size_t kCurveSpanCacheLimit = 32;
/** /**
* @brief 进入「区间窗口缓存」的单条窗口采样数上限。 * @brief 进入「区间窗口缓存」的单条窗口采样数上限。
* *
* 超过它的窗口(例如「中心轴边沿计数」在未限定光标区间时的整条信号) * **必须足够大**,使「完全放大到整条信号」的窗口也能进缓存:否则每一帧(含静止时
* 改写进 `m_curveSpanScratch`:缓存的窗口一旦被淘汰就得重算整条曲线 * 的鼠标悬停重绘)都要重算整条曲线 —— 弱波增强在 2576 万采样上单条 ≈368ms,
* (亿级采样实测 ≈1s 的卡顿尖峰),而且几条曲线同时驻留就会吃掉数百 MB。 * 5 条网格就是 ≈1.8s/帧,交互完全无法进行。
*
* 窗口一旦计算完成,同一参数下(平移/缩放/悬停)都能直接复用;
* 内存总量由 `kCurveSpanCacheMaxBytes` 兜底,因此这里不再按采样数排除大窗口。
*/ */
const std::size_t kCurveSpanCacheMaxSamples = 4194304; const std::size_t kCurveSpanCacheMaxSamples = 67108864;
/** 区间窗口缓存允许占用的总字节数(约 768MB):超出时按「最旧优先」淘汰。
* 取足够大是为了让「完全放大时 5 个网格各缓存一条整段曲线」可以同时驻留,
* 否则缓存互相淘汰会导致每帧重算其中一条(弱波增强 ≈368ms 的卡顿尖峰)。 */
const std::size_t kCurveSpanCacheMaxBytes = 768ull * 1024ull * 1024ull;
/**
* @brief 交互期间允许**同步重算**的派生曲线窗口采样数上限。
*
* 拖动滑块 / 移动视界 / 缩放等交互每帧都会请求派生(预览)曲线。窗口不大时同步重算
* 足够快(几毫秒),预览曲线能实时跟随控件;一旦窗口超过本上限(例如大时间跨度或
* 整条信号),同步重算会阻塞交互(弱波增强整条信号单条 ≈368ms)。
*
* 此时**不在交互过程中重算**,而是复用上一次算好的曲线(位置由当前视图映射,仍准确),
* 等交互结束后再按最终参数精确重算 —— 控件(滑块/光标/视界)的反馈因此始终跟手。
*/
const std::size_t kDerivedInteractiveComputeBudget = 131072;
const wchar_t* const kWeakBoostLevelLabel = L"弱波增强等级"; /**< 等级滑块行左侧的说明文字。 */ const wchar_t* const kWeakBoostLevelLabel = L"弱波增强等级"; /**< 等级滑块行左侧的说明文字。 */
const wchar_t* const kWeakBoostIterationsLabel = L"迭代次数 N"; /**< 迭代次数滑块行左侧的说明文字。 */ const wchar_t* const kWeakBoostIterationsLabel = L"迭代次数 N"; /**< 迭代次数滑块行左侧的说明文字。 */
const wchar_t* const kWeakBoostTitle = L"弱波增强"; /**< 弱波增强面板标题。 */ const wchar_t* const kWeakBoostTitle = L"弱波增强"; /**< 弱波增强面板标题。 */
@@ -2107,7 +2123,8 @@ namespace jdi
const std::vector<float>* PlotView::CurveSpanFor(DerivedCurveKind kind, const SignalData& signal, const std::vector<float>* PlotView::CurveSpanFor(DerivedCurveKind kind, const SignalData& signal,
int points, int iterations, int levelPercent, int points, int iterations, int levelPercent,
std::size_t begin, std::size_t end, std::size_t begin, std::size_t end,
std::size_t& spanBegin) const std::size_t& spanBegin,
bool allowInteractiveDefer) const
{ {
spanBegin = 0; spanBegin = 0;
const std::size_t count = signal.values.size(); const std::size_t count = signal.values.size();
@@ -2143,6 +2160,43 @@ namespace jdi
} }
} }
// ⚠️ 交互期间的「延迟重算」:拖动滑块 / 移动视界 / 缩放每帧都会请求预览曲线,
// 若窗口很大(例如大时间跨度、整条信号),同步重算会阻塞交互数毫秒到数百毫秒
// (弱波增强整条信号单条 ≈368ms)。此时**不重算**,改复用上一次算好的曲线:
// 曲线下标映射到时间仍是当前视图的映射,因此位置依旧准确,只是参数/覆盖范围可能是
// 上一帧的;等交互结束后(`m_dragMode == NONE`)再按最终参数精确重算。
// 这样滑块 / 光标 / 视界移动的反馈始终跟手,不会被绘制计算拖住。
if (allowInteractiveDefer && m_dragMode != PlotDragMode::NONE &&
(end - begin) > kDerivedInteractiveComputeBudget)
{
const CurveSpanEntry* covering = nullptr;
const CurveSpanEntry* newest = nullptr;
for (auto it = m_curveSpanCache.rbegin(); it != m_curveSpanCache.rend(); ++it)
{
if (it->signal != &signal || it->kind != kindValue)
{
continue;
}
if (newest == nullptr)
{
newest = &*it;
}
if (it->begin <= begin && it->end >= end)
{
covering = &*it;
break;
}
}
const CurveSpanEntry* fallback = (covering != nullptr) ? covering : newest;
if (fallback != nullptr)
{
spanBegin = fallback->begin;
return &fallback->values;
}
// 完全没有可复用的曲线:本帧跳过该预览曲线(不阻塞交互)。
return nullptr;
}
// 未命中:向外扩一个保护带后再计算,保证可见区间内的取值与"整条曲线"一致。 // 未命中:向外扩一个保护带后再计算,保证可见区间内的取值与"整条曲线"一致。
// 保护带取窗口长度的 1/8(夹在 4096~65536):弱波增强的修正沿相邻极值向内传播 // 保护带取窗口长度的 1/8(夹在 4096~65536):弱波增强的修正沿相邻极值向内传播
// (每迭代一次传一个极值),1/8 个可见宽度足以覆盖真实信号的极值间距。 // (每迭代一次传一个极值),1/8 个可见宽度足以覆盖真实信号的极值间距。
@@ -2185,9 +2239,23 @@ namespace jdi
return &m_curveSpanScratch; return &m_curveSpanScratch;
} }
if (m_curveSpanCache.size() >= kCurveSpanCacheLimit) // 缓存窗口:条目数与总字节数双重约束,避免大窗口把内存吃满。
// 淘汰策略为「最旧优先」(`deque` 头部即最早写入的)。
const auto cacheBytes = [this]() -> std::size_t
{ {
m_curveSpanCache.clear(); std::size_t total = 0;
for (const CurveSpanEntry& cached : m_curveSpanCache)
{
total += cached.values.size() * sizeof(float);
}
return total;
};
while (!m_curveSpanCache.empty() &&
(m_curveSpanCache.size() >= kCurveSpanCacheLimit ||
cacheBytes() + (computedEnd - computedBegin) * sizeof(float) >
kCurveSpanCacheMaxBytes))
{
m_curveSpanCache.pop_front();
} }
m_curveSpanCache.push_back(CurveSpanEntry()); m_curveSpanCache.push_back(CurveSpanEntry());
CurveSpanEntry& entry = m_curveSpanCache.back(); CurveSpanEntry& entry = m_curveSpanCache.back();
@@ -4761,6 +4829,12 @@ namespace jdi
m_hoverSignal = SignalRef(); m_hoverSignal = SignalRef();
changed = true; changed = true;
} }
// 图例悬停半高亮同样随鼠标离开而消失(否则右键弹菜单/移出绘图区后残留在原地)。
if (m_hoverLegend.IsValid())
{
m_hoverLegend = SignalRef();
changed = true;
}
if (m_cursorHover) if (m_cursorHover)
{ {
m_cursorHover = false; m_cursorHover = false;
@@ -7372,22 +7446,26 @@ namespace jdi
} }
const int otherIndex = const int otherIndex =
showDelta ? FindNearestSample(*resolved.file, otherTime) : -1; showDelta ? FindNearestSample(*resolved.file, otherTime) : -1;
const std::size_t count = // 本函数**只需要光标所在采样点处的派生取值**(以及双光标另一端的取值),
std::min(resolved.file->time.size(), resolved.data->values.size()); // 因此把请求窗口收窄到「需要的那几个下标」即可,均值滤波半径造成的取值
std::size_t begin = 0; // 依赖由 `CurveSpanFor` 自身的保护带负责覆盖。
std::size_t end = 0; //
VisibleIndexRange(*resolved.file, count, begin, end); // ⚠️ 绝不能把窗口扩成「光标 ↔ 视界边缘」的并集:光标停在视界之外(例如
begin = std::min(begin, static_cast<std::size_t>(sampleIndex)); // 平移视界把光标甩到很远,或光标远离当前缩放区间)时会得到一个上千万采样的
end = std::max(end, static_cast<std::size_t>(sampleIndex) + 1); // 巨大窗口,超过缓存上限后走一次性缓冲 → **每帧重算整段滤波**,
// 实测单帧 300ms+(用户报告的"平移/拖动光标异常卡顿"的直接原因)。
std::size_t begin = static_cast<std::size_t>(sampleIndex);
std::size_t end = begin + 1;
if (otherIndex >= 0) if (otherIndex >= 0)
{ {
begin = std::min(begin, static_cast<std::size_t>(otherIndex)); const std::size_t other = static_cast<std::size_t>(otherIndex);
end = std::max(end, static_cast<std::size_t>(otherIndex) + 1); begin = std::min(begin, other);
end = std::max(end, other + 1);
} }
std::size_t spanBegin = 0; std::size_t spanBegin = 0;
const std::vector<float>* curvePtr = const std::vector<float>* curvePtr =
CurveSpanFor(kind, *resolved.data, points, iterations, levelPercent, begin, CurveSpanFor(kind, *resolved.data, points, iterations, levelPercent, begin,
end, spanBegin); end, spanBegin, true);
if (curvePtr == nullptr) if (curvePtr == nullptr)
{ {
return; return;
@@ -8153,7 +8231,8 @@ namespace jdi
} }
std::size_t spanBegin = 0; std::size_t spanBegin = 0;
const std::vector<float>* curve = CurveSpanFor(DerivedCurveKind::MEAN_FILTER, *resolved.data, const std::vector<float>* curve = CurveSpanFor(DerivedCurveKind::MEAN_FILTER, *resolved.data,
points, iterations, 0, begin, end, spanBegin); points, iterations, 0, begin, end, spanBegin,
true);
if (curve == nullptr) if (curve == nullptr)
{ {
return; return;
@@ -8178,7 +8257,8 @@ namespace jdi
} }
std::size_t spanBegin = 0; std::size_t spanBegin = 0;
const std::vector<float>* curve = CurveSpanFor(DerivedCurveKind::WEAK_BOOST, *resolved.data, 0, const std::vector<float>* curve = CurveSpanFor(DerivedCurveKind::WEAK_BOOST, *resolved.data, 0,
iterations, levelPercent, begin, end, spanBegin); iterations, levelPercent, begin, end, spanBegin,
true);
if (curve == nullptr) if (curve == nullptr)
{ {
return; return;
+228
View File
@@ -0,0 +1,228 @@
# Just DataInspector — Agent 记忆与项目须知
> 本文件是本仓库的**权威上下文备忘录**,供任何接手的 AI Agent 在动手前阅读。
> **每次完成任务后必须同步更新本文件**(补充新需求、修复记录、约定变更等)。
> 保持内容**精确、明细、简练**;多台设备协作时,可将各自的记忆合并到本文件。
---
## 1. 项目目标
- 使用 **C++17 + Win32 API(GDI/GDI+)** 从零构建的桌面数据查看器,项目名 **Just DataInspector**。
- 定位:替代 MATLAB/Simulink 的 `Simulink.sdi.view` 仿真数据检查器,用于更方便地查看**带时间轴的 CSV** 仿真数据。
- 目标数据特征(参考 MathWorks 导入 CSV 的说明):CSV 中存在**一个或多个时间轴列**,其余列表示各时间点上的信号值。
- 产物为 **GUI 程序**(非 CUI)。
---
## 2. 构建与工具链
- 工具链:**MinGW-w64**(`x86_64-win32-seh-rev0`, MinGW-Builds 16.1.0),可直接用 `make`。
- 构建入口:根目录 `Makefile`(C / C++ / windres 混合构建)。
- 常用命令:
- `make release` → `Build/Release/Target.exe`(`-Os`,strip:`-s`)。
- `make debug` → `Build/Debug/Target.exe`(`-O0 -g`)。
- `make` → 同时构建 Release 与 Debug。
- `make clean` → 删除 `Build/`。
- 关键编译设置:`-std=c++17`;`-finput-charset=UTF-8 -fexec-charset=GBK -fwide-exec-charset=UTF-16LE`;`-static`;`-mwindows`(GUI);链接 `gdi32 gdiplus comdlg32 d3d11 dwmapi d3dcompiler`。
- **新增组件/工具目录后,必须在 `Makefile` 的 `CXX_INCLUDES_PATHS` 与 `CXX_SOURCES_PATHS` 中登记**(源文件按目录通配自动收集)。
- 构建产物统一在 `Build/`,已由 `.gitignore` 忽略;同时忽略 `*.o/*.d/*.lst/*.map/*.exe` 与 `TestData/`。
---
## 3. 目录结构与约定
```
Components/ 功能组件块(每块 Inc/ 放 .hpp,Src/ 放 .cpp)
Application/ 应用主框架:窗口、布局、消息分发、标签页、绘制合成
Main/ 程序入口(WinMain → Application::Run)
TabBar/ 顶部多标签栏
Toolbar/ 顶部工具栏(导入文件 / 适应视图 / 时间轴自适应 / Y 轴自适应 / 选区放大 / 光标 / 合并·拆分·清空网格)
SignalPanel/ 左侧信号面板:按文件分组的信号树、可见性勾选、样式编辑
PlotView/ 波形绘图区:网格、时间轴、光标、缩放、拖拽、实验性功能面板
Utilities/ 工具与辅助模块(Inc/ + Src/)
Application/ ... (见上)
CsvTable/ CSV 流式解析(列优先)
DataModel/ 数据层:DataSet / DataFile / SignalData / 显示配置
PaintUtil/ GDI+ 绘制与文本/控件辅助(含 UiPointInRect、UiMeasureTextWidth 等)
PopupMenu/ 主题化自绘弹出菜单(含二级子菜单、内联编辑)
ColorPickerPopup/ 浮动拾色器
TextEdit/ 内联文本编辑模型
Theme/ 主题色板(Solarised Light)与波形默认配色
Utf8Util/ 编码转换(UTF-8 ↔ UTF-16 等)
Resources/ 图标、清单(.manifest)、版本信息(.rc)、ResourceIds.h
TestData/ 本地测试 CSV(**不纳入版本控制**)
Documents/ 项目文档(含本文件)
```
- **命名硬性约束**:源文件/头文件**不得含空格**,只能字母与下划线组合。
- **头文件后缀**:C 用 `.h`,C++ 用 `.hpp`;C 源 `.c`,C++ 源 `.cpp`。
- **防重复包含**:每个头文件必须 `#ifndef __XXX_XX_HPP__ / #define __XXX_XX_HPP__ / ... / #endif`;宏名 = 文件名的双边双下划线 + 全大写。
- **宏定义与枚举常量必须全大写 + 下划线**。
- 所有对外函数/方法声明用 Doxygen 风格注释:`@brief`、`@param [IN/OUT]`、`@return`(中文说明)。
- 代码风格统一,强调**解耦**与清晰的分层。
---
## 4. 架构要点
- **数据层共享、视图配置分标签页**:全局 `DataSet`(已加载文件、信号、时间轴)被所有标签页共享;每个标签页仅持有各自的 `TabConfig`(信号可见性、配色、分组、网格等),互不影响。
- `Application` 是总编排者:创建窗口、`Relayout` 布局、`HandleMessage` 分发消息、合成绘制、把菜单/拾色器结果派发回组件。
- 自定义绘制的浮动控件(`PopupMenu`、`ColorPickerPopup`)**不创建独立窗口**,作为宿主绘制的**最上层内容**直接画进后备缓冲区,保证外观与主界面统一(不出现系统默认菜单样式)。
- 消息优先级:菜单打开 > 拾色器打开 > 常规组件交互(见 `Application::HandleMessage`)。
- DPI 感知:`ApplyDpi` 重建字体与布局;浮动控件通过 `SetScale` 缩放。
### 数据模型(`Utilities/Inc/DataModel.hpp`)
- `SignalData`:单条信号元数据 + 采样值。
- `DataFile`:一个已加载文件及其**自有时间轴**(`timeColumnName` / `time` / `minTime` / `maxTime` / `timeMonotonic`)。
- `DataSet`:文件集合与全局时间范围查询(`MinTime/MaxTime`、最近采样点查找等)。
- `DrawMode`:`LINE`(插值折线) / `SCATTER`(散点) / `LINE_SCATTER`(两者)。
- `LaneMode`:`PER_SIGNAL` / `MERGED` / `CUSTOM`(网格划分策略)。
- `PlotCursorMode`:`NONE` / `SINGLE` / `DOUBLE`(时间光标三态)。
- 各网格自身状态(Y 轴范围、自适应开关、实验性功能开关等)随信号继承。
### CSV 解析(`Utilities/Src/DataModel.cpp`)
- 列优先存储(每条信号一整块连续内存):避免百万行级按行存储的堆分配放大,并支持整列**移动**进 `SignalData`。
- 流式分块读取,可携带真实字节进度(`CsvProgressInfo`),支持取消。
- **时间列判定** `LooksLikeTimeHeader`:小写匹配 `t / time / timestamp / simulation time`,或包含 `time` / `时间`;未命中时回退第 0 列。
- 缺失值以 `NaN` 表示;要求时间列有效数值 ≥ 2,否则报错。
- 分隔符自动探测(默认 `,`)。
---
## 5. 功能需求清单(来自需求交互,持续补充)
### 5.1 基础功能(首轮构建目标,已实现)
- 波形网格(多网格);时间轴。
- 光标(单/双光标,含差值读数)。
- 缩放与自适应(适应视图、Y 轴自适应、选区放大)。
- 导入并加载符合要求(带时间轴列)的 CSV,**解析出信号名**。
- 信号样式:**修改颜色、粗细、散点/插值线段显示方式**(`DrawMode`)。
- 信号可绘制到**同一网格**或**不同网格**。
### 5.2 后续扩展(已在代码中实现,供接手参考)
- 多标签页(新建/关闭/重命名/拖拽排序)。
- 工具栏:合并网格 / 拆分网格 / 清空网格;时间轴自适应。
- 信号面板:按文件分组、勾选可见性、仅显示/全部显示/隐藏全部、移除文件。
- 绘图区拖拽:平移、选区放大(按首个明显位移方向决定横/纵轴)、网格滚动、光标拖拽、**把信号曲线拖到其它网格**、网格图例 GX 徽标上下重排。
- 图例:悬停高亮、内联重命名、信号删除按钮。
### 5.3 「实验性功能」二级菜单(网格右键菜单内,横线上方)
包含且仅包含以下可勾选项(每网格独立状态):
- **边沿计数**
- **边沿计数(中心轴)**
- **均值滤波**
- **弱波增强**
这些功能开关会联动绘图区的浮动参数面板;部分支持「构建为新信号」。
> 注意:新增/改名实验性功能时,需同步 `PlotView.hpp` 的 `PLOT_CMD_LANE_*`、`Application.cpp` 的菜单构建与派发、`PlotView`/`DataModel` 的状态位与面板绘制。
---
## 6. UI / 交互设计语言
- 参考 **VS Code** 与 **WinUI** 的设计语言;配色为 **VS Code 的 Solarised Light** 方案(`Theme::SolarizedLight()`,强调色 `accent`)。
- 全部界面与交互用 **Win32 API** 自绘,不用系统默认控件样式。
- 文本对齐、菜单内边距、圆角、阴影等在各浮动控件内保持统一风格。
---
## 7. 交付与 Git 规范
- 工作区由**本地 git** 管辖。
- **仅在功能完善(可验证)后才提交**;提交信息用简洁中文描述修改内容。
- 提交时**仅对本工作区**生效(`git config --local`),**不得影响全局设置**:
- `user.name` = 当前模型全称。
- `user.email` = `<>`(留空尖括号)。
- `TestData/`、`Build/` 等不提交(见 `.gitignore`)。
---
## 8. Agent 工作流要求
1. 接手前先**探索工作区/提交历史/代码**建立认知(本文件应作为第一参考)。
2. 完成需求后**编译验证**(`make release` 至少成功),必要时用 `TestData/` 数据功能验证。
3. **验证与探索时尽量不占用用户的鼠标/屏幕/键盘**(后台构建为主)。
4. 本地仓库提交完成后,**不要在未经指示的情况下继续探索或优化**,等待用户下一步指令。
5. **每次完成任务后更新本文件**(需求、修复、约定变更)。
---
## 9. 已知问题与修复记录
### 9.1 网格右键菜单「实验性功能」二级菜单瞬间关闭(已修复)
- **症状**:在网格右键菜单里把鼠标从「实验性功能」移向右侧二级菜单的途中,只要指针短暂离开父项交互区域,二级菜单立即关闭,用户来不及完成选择。旧设备 CPU 较慢、鼠标移动事件稀疏,指针可能"跳"进二级菜单而未暴露该问题;CPU 更快、事件更密集时必然复现。
- **根因**(`Utilities/Src/PopupMenu.cpp` 的 `PopupMenu::OnMouseMove`):
- 子菜单展开后,只要指针**不在子菜单矩形内**且**未命中任何可点击条目**,就无条件 `CloseSubmenu()`。
- 主菜单条目矩形的右缘为 `menu.right - kPadH`,而子菜单左缘为 `menu.right - kSubOverlap`,两者之间存在约 3px 的**内边距"死区"**(位于主菜单矩形内但落在任何条目之外)。鼠标事件密集时指针必然落入该窄带,从而触发误关闭。
- **修复**:子菜单展开期间,若指针仍位于「主菜单 ∪ 子菜单」矩形范围内、且尚未落到**其它可点击条目**上(例如掠过内边距窄带、分隔线、禁用项),则保持子菜单展开并维持父项高亮;只有明确移动到其它条目或完全离开上述范围时才收起。
### 9.2 打开网格右键菜单后,悬停光晕与「可拖拽」提示气泡不消失(已修复)
- **症状**:鼠标停在信号曲线上(出现光晕高亮 + 「XXX · 按住拖拽可移入其它网格」提示气泡)时右键弹出网格菜单,高亮与气泡**残留在原地**,不随菜单打开而消失。
- **根因**:菜单打开后鼠标移动被菜单独占(`Application::HandleMessage` 在 `m_menu.IsOpen()` 时只把事件交给菜单),绘图区再也收不到 `WM_MOUSEMOVE`;而打开菜单时**没有清空**绘图区的悬停状态(`m_hoverSignal` / `m_hoverLegend`),因此 `PaintHoverHighlight` 与 `PaintSignalInteraction` 继续把高亮与气泡画出来。
- **修复**:
- `Application` 在绘图区右键弹出网格菜单前调用 `m_plot.OnMouseLeave()`,清空悬停状态;
- `PlotView::OnMouseLeave()` 补上清空 `m_hoverLegend`(原先只清 `m_hoverLane/m_hoverSignal` 等,图例半高亮会残留)。
### 9.3 性能:启用带预览曲线的实验性功能后,平移/拖动光标异常卡顿(已修复)
- **症状**(以 `VERY_BIG_DATA.CSV` 为例,加载并显示全部信号后):时间轴选区放大到某些区间时,左键/中键平移网格视界、或启用「均值滤波 / 中心轴边沿计数」后再平移、甚至只是左右拖动时间光标看数值,都会**异常卡顿**(实测单帧 300ms+)。
- **排查方法**:在 `.preflight/perf/`(gitignored)用离屏窗口(不占用鼠标/键盘/屏幕)直接驱动 `PlotView`,用 `QueryPerformanceCounter` 测 `Paint()`,并对 `CurveSpanFor` / `ComputeMovingAverageIterated` / `PaintDerivedCurve` 分段埋点计时。
- **根因**(`Components/PlotView/Src/PlotView.cpp` 的 `PaintCursor` → `paintDerivedCapsule` lambda):
- 绘制时间光标处的**派生曲线取值胶囊**时,把请求窗口取成「**可见区间 ∪ 光标采样下标**」的并集;
- 当光标不在当前视界内(平移视界会把光标甩到视界外)时,这个并集会变成 `[光标下标, 视界右端]` 或 `[视界左端, 光标下标]` —— 上千万采样的**巨大窗口**;
- 该窗口超过 `kCurveSpanCacheMaxSamples`(4M) → 走一次性缓冲、**不进缓存** → **每一帧都重算整段多次迭代滑动平均**(实测 5 条曲线合计 300ms+)。
- 这条路径同样被平移、拖滑块、拖动光标、改色等所有「每帧重绘」的交互触发,因此它们一起变卡。
- **修复**:`paintDerivedCapsule` 只需要光标所在采样点(以及双光标另一端)的派生取值,因此把请求窗口收窄为「**需要的那几个下标**」(`[sampleIndex, sampleIndex+1]`,双光标时并入另一端)。均值滤波半径造成的取值依赖由 `CurveSpanFor` 自身的保护带(`guard = clamp(max(窗口/8, (X+1)*N+64), 4096, 65536)`)负责覆盖 —— 对一切 `reach ≤ 65536` 的参数,取值与「整条曲线」**逐位一致**,显示效果零变化。
- **效果**(离屏实测,`VERY_BIG_DATA`,5 网格):
- 均值滤波平移(2% 跨度):**326ms → 约 16–23ms**;
- 均值滤波平移(0.4%/0.08%/0.016%):**约 305ms → 4–6ms**;
- 中心轴边沿计数平移(2%):**约 300ms → 约 20ms**;
- 光标左右移动(静态场景):命中场景缓存,仅叠加层重绘,约 9ms。
- **不影响显示的说明**:本次**未**改动任何几何/绘制算法(没有引入曾导致"棘状细节被并成实心块"的 ±1 列扩展),只收窄了「为了取时间光标处派生取值而请求的曲线窗口」,因此画面与实际取值完全不变。
### 9.4 性能经验与已知残留热点(供后续参考)
- **本仓库的性能黄金法则**:交互期间(`m_dragMode != NONE`)走「快速帧」(关闭波形/派生曲线抗锯齿),松手后补一帧全质量;静止画面走「静态场景缓存」(BitBlt 贴图 ~1ms)。任何**只在鼠标移动/拖动参数时改变**的内容都必须画在贴图之后(叠加层),绝不能进静态缓存,也不能每帧触发整条曲线/整段采样的重算。
- **已知残留热点**(本次未动,均为"非拖拽的整幅重绘",实际拖拽时因快速帧而很快):
- 原始波形的「逐像素列包络带」用 GDI+ `FillPath` 填充;在某些跨度下包络带不被判为「梳状」,抗锯齿填充仍约 20–27ms/帧(`VERY_BIG_DATA` 的全量重绘)。这是 GDI+ 抗锯齿路径的固有代价;如需进一步提速,**只能**降低列分辨率或对该形态关闭抗锯齿 —— 两者都会改变显示效果,需用户确认后再动。
- 「构建为新信号」等一次性操作会真正计算整条派生曲线(约 1 帧的秒级耗时,正常)。
- **测量工具**:`.preflight/perf/perf.exe <csv> [mode]`(离屏,不弹窗)。`mode=0` 跑各交互场景耗时;`mode=1` 导出确定性渲染 PNG 以便逐像素比对(用于验证"显示效果不变")。该目录已由 `.gitignore` 忽略,不会入库。
### 9.5 性能:启用带预览曲线的实验性功能后,交互严重卡顿(核心修复,含"为什么只有某些缩放等级卡"的解答)
- **症状**(以 `VERY_BIG_DATA.CSV`,5 信号全部显示为例):
- 「弱波增强」滑块**拖动时**在**任何缩放等级**下都卡顿;越缩得小(可见时间跨度越大)越卡 —— 时间跨度到 2.5s 时单帧 ≈89ms,完全放大(整段 128.8s)时单帧 **≈2.3 秒**。
- 网格「选区放大」到某些跨度后,平移视界 / 改样式也卡。
- **根因**(`Components/PlotView/Src/PlotView.cpp`,`CurveSpanFor` + 缓存策略 + `Utilities/Src/DataModel.cpp` 的 `ComputeWeakWaveBoost`):
1. 派生(预览)曲线按「可见采样窗口 ± 保护带」计算,**代价正比于窗口内的采样数**。实测:弱波增强整段(2576 万采样)单条 **≈368ms**,均值滤波整段单条 ≈57ms。5 个网格就是 ≈1.8s。
2. 旧代码有一条「**超过 4M 采样的窗口不进缓存**」的规则(为省内存),于是**大跨度下每帧都重算**整段曲线 —— 包括静止时的鼠标悬停重绘。这就是"某些缩放等级特别卡"的原因:
- 完全放大:窗口 = 整条信号(千万级)→ 每帧重算 → 秒级卡顿;
- 很小的时间跨度:窗口只有几千采样 → 计算只要微秒级 → **毫不卡顿**;
- 中间跨度:窗口在十几万~几百万采样之间 → 单帧几十毫秒 → 卡,且保护带(≤65536)相对窗口太短,平移很容易越出缓存覆盖 → 频繁重算。
3. 拖动「弱波增强」滑块每帧都改等级 → 缓存键变化 → 每帧都命中"未缓存"→ 每帧重算(叠加上述放大效应)。
- **修复**(三管齐下,**显示效果逐像素不变**):
1. **大窗口也进缓存**:`kCurveSpanCacheMaxSamples` 提高到 64M(相当于不再按采样数排除),并新增**总字节预算** `kCurveSpanCacheMaxBytes`(≈768MB,最旧优先淘汰)兜底内存。这样同一参数下的平移/缩放/悬停都能复用,静止重绘降到 **0.25ms**。
2. **交互期间「延迟重算」**(`CurveSpanFor` 新增 `allowInteractiveDefer`):拖动滑块/移动视界/缩放时,若所需窗口超过 `kDerivedInteractiveComputeBudget`(131072 采样),**本帧不重算**,改复用上一次算好的曲线(曲线下标→时间的映射仍是当前视图,因此位置准确),交互结束后再按最终参数精确重算。窗口不大时(常见工作缩放)照常同步重算,预览曲线**实时跟随控件**。
- 用途区分:**预览绘制**(`PaintMeanFilterCurve`/`PaintWeakBoostCurve`/光标取值胶囊)传 `allowInteractiveDefer = true`;**需要精确取值的统计**(中心轴边沿计数 `EdgeCenterCountsFor`、`MeanFilterValueAt`/`WeakBoostValueAt`)传 `false`(默认)。
3. **计算缓冲复用**:`ComputeWeakWaveBoost` / `ComputeMovingAverageIterated` / `ApplyWeakBoostStage` 的工作缓冲改为 `thread_local` 复用容量,避免每次重算都分配并零初始化上千万元素(多阶弱波增强 991ms→577ms)。
- **实测**(离屏,`VERY_BIG_DATA`,5 网格;单位 ms/帧):
| 场景 | 修复前 | 修复后 |
| --- | --- | --- |
| 弱波增强 滑块拖动(整段) | ≈2303 | **≈18** |
| 弱波增强 滑块拖动(2.5s 跨度) | ≈89 | **≈15** |
| 弱波增强 滑块拖动(103ms / 20ms 跨度) | ≈14 / ≈12 | ≈15 / ≈11 |
| 弱波增强 静止重绘(整段) | ≈2247 | **≈0.25** |
| 均值滤波 平移(2.5s 跨度) | ≈27 | ≈23(大窗口非拖拽时仍按需重算;拖拽时延迟) |
| 普通平移 / 光标移动 / 改色 | 3–12 | 不变(1–12) |
- **已知残留**(不牺牲显示时的固有成本):把可见时间跨度拉到接近整段且开启弱波增强时,**首次**计算某参数/窗口的预览曲线仍需要一次 ≈1.8s(5 条整段曲线,各 ≈368ms)—— 交互期间不会触发(已延迟),但交互结束后的那一帧会补上。这是"整段弱波增强 O(n) 计算"的固有代价;若要进一步消除需把该计算移到后台线程(尚未做,改动较大)。
- **为什么这样改不牺牲显示效果**:本次只改「何时计算 / 是否缓存」,不改任何几何或取值算法;`mode=1` 的逐像素比对(均值滤波 + 弱波增强,两个缩放级别,含光标取值胶囊)结果为 **0 像素差异**。
---
## 10. 变更日志(本文件维护记录)
| 日期 | 设备/模型 | 摘要 |
| --- | --- | --- |
| 2026-10-11 | DeepSeek-V4.1-Flash | 新建本文件;记录项目要点与规范;修复「实验性功能」二级菜单瞬间关闭问题。 |
| 2026-10-11 | DeepSeek-V4.1-Flash | 修复右键网格菜单后悬停光晕/提示气泡残留;修复「启用带预览曲线的实验性功能后平移/拖光标 300ms+ 卡顿」(`paintDerivedCapsule` 巨窗每帧重算,收窄请求窗口,显示效果零变化)。 |
| 2026-10-11 | DeepSeek-V4.1-Flash | 深度修复「实验性功能预览曲线导致的交互卡顿」:大窗口改入缓存(静止重绘 2247ms→0.25ms)、交互期间延迟重算(弱波增强拖滑块 2303ms→18ms)、计算缓冲复用;显示逐像素不变。 |
+18 -7
View File
@@ -1032,7 +1032,10 @@ namespace jdi
} }
// 后续各阶以上一阶的结果为输入(double 中转,避免 float→double 的精度损失累积)。 // 后续各阶以上一阶的结果为输入(double 中转,避免 float→double 的精度损失累积)。
std::vector<double> buffer(values.size(), std::numeric_limits<double>::quiet_NaN()); // 中转缓冲复用容量(线程本地),避免每阶重新分配上千万元素的向量。
thread_local std::vector<double> bufferBuffer;
std::vector<double>& buffer = bufferBuffer;
buffer.assign(values.size(), std::numeric_limits<double>::quiet_NaN());
std::vector<float> stage; std::vector<float> stage;
for (int iteration = 1; iteration < stages; ++iteration) for (int iteration = 1; iteration < stages; ++iteration)
{ {
@@ -1070,7 +1073,7 @@ namespace jdi
* @return 确实有采样点被改动返回 true;完全无弱波时返回 false。 * @return 确实有采样点被改动返回 true;完全无弱波时返回 false。
*/ */
bool ApplyWeakBoostStage(const std::vector<float>& input, double strength, bool ApplyWeakBoostStage(const std::vector<float>& input, double strength,
std::vector<float>& output) std::vector<float>& output, std::vector<BoostExtremum>& extremes)
{ {
const std::size_t count = input.size(); const std::size_t count = input.size();
output.assign(count, std::numeric_limits<float>::quiet_NaN()); output.assign(count, std::numeric_limits<float>::quiet_NaN());
@@ -1092,7 +1095,7 @@ namespace jdi
// 1) 识别波峰 / 波谷:扫描单调段,方向翻转处的上一个采样点即为极值点。 // 1) 识别波峰 / 波谷:扫描单调段,方向翻转处的上一个采样点即为极值点。
// 波峰(先升后降)与波谷(先降后升)必然交替出现, // 波峰(先升后降)与波谷(先降后升)必然交替出现,
// 因此同类极值就是极值序列里下标 ±2 的两个。 // 因此同类极值就是极值序列里下标 ±2 的两个。
std::vector<BoostExtremum> extremes; extremes.clear();
{ {
int direction = 0; // 当前单调段方向(+1 上升、-1 下降)。 int direction = 0; // 当前单调段方向(+1 上升、-1 下降)。
bool hasPrevious = false; bool hasPrevious = false;
@@ -1145,7 +1148,9 @@ namespace jdi
// 2) 逐个极值点判定“是否偏弱”,并算出它自己的目标偏移量。 // 2) 逐个极值点判定“是否偏弱”,并算出它自己的目标偏移量。
const std::size_t extremeCount = extremes.size(); const std::size_t extremeCount = extremes.size();
std::vector<double> shift(extremeCount, 0.0); thread_local std::vector<double> shiftBuffer;
std::vector<double>& shift = shiftBuffer;
shift.assign(extremeCount, 0.0);
for (std::size_t k = 2; k + 2 < extremeCount; ++k) for (std::size_t k = 2; k + 2 < extremeCount; ++k)
{ {
const BoostExtremum& current = extremes[k]; const BoostExtremum& current = extremes[k];
@@ -1241,7 +1246,13 @@ namespace jdi
} }
// 第 1 阶:从原始采样值出发(非有限值保持 NaN)。 // 第 1 阶:从原始采样值出发(非有限值保持 NaN)。
std::vector<float> current(count, std::numeric_limits<float>::quiet_NaN()); // 工作缓冲用线程本地变量复用容量:单条曲线动辄上千万采样,若每次调用都重新分配
// 并零初始化,单条「弱波增强」的耗时会被明显放大(内存分配 + page fault)。
thread_local std::vector<float> currentBuffer;
thread_local std::vector<float> nextBuffer;
thread_local std::vector<BoostExtremum> extremesBuffer;
std::vector<float>& current = currentBuffer;
current.assign(count, std::numeric_limits<float>::quiet_NaN());
for (std::size_t index = 0; index < count; ++index) for (std::size_t index = 0; index < count; ++index)
{ {
const double value = values[index]; const double value = values[index];
@@ -1256,10 +1267,10 @@ namespace jdi
// 多次迭代时相邻的弱波会互相抬高彼此的基准,从而把较宽的弱波区逐步恢复。 // 多次迭代时相邻的弱波会互相抬高彼此的基准,从而把较宽的弱波区逐步恢复。
const int stages = std::max(1, std::min(iterations, 1000)); const int stages = std::max(1, std::min(iterations, 1000));
const double strength = std::max(0.0, std::min(1.0, level)); const double strength = std::max(0.0, std::min(1.0, level));
std::vector<float> next; std::vector<float>& next = nextBuffer;
for (int stage = 0; stage < stages; ++stage) for (int stage = 0; stage < stages; ++stage)
{ {
if (!ApplyWeakBoostStage(current, strength, next)) if (!ApplyWeakBoostStage(current, strength, next, extremesBuffer))
{ {
// 这一阶没有任何采样点需要调整:后续迭代次数也不会再有变化,提前结束。 // 这一阶没有任何采样点需要调整:后续迭代次数也不会再有变化,提前结束。
break; break;
+23 -4
View File
@@ -678,10 +678,12 @@ namespace jdi
return false; return false;
} }
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
bool changed = false; bool changed = false;
// 鼠标在展开的子菜单内:只更新子菜单的悬停项(主菜单父项保持高亮)。 // 鼠标在展开的子菜单内:只更新子菜单的悬停项(主菜单父项保持高亮)。
if (m_subOpen && UiPointInRect(m_subRect, static_cast<float>(x), static_cast<float>(y))) if (m_subOpen && UiPointInRect(m_subRect, fx, fy))
{ {
const int sub = SubItemAtPoint(x, y); const int sub = SubItemAtPoint(x, y);
if (sub != m_subHoverIndex) if (sub != m_subHoverIndex)
@@ -694,6 +696,25 @@ namespace jdi
int swatchIndex = -1; int swatchIndex = -1;
const int index = ItemAtPoint(x, y, swatchIndex); const int index = ItemAtPoint(x, y, swatchIndex);
const bool onSubmenuParent =
(index >= 0) && (m_items[static_cast<std::size_t>(index)].kind == MenuItemKind::SUBMENU);
// 子菜单展开期间,鼠标只要仍停留在「主菜单 ∪ 子菜单」的范围内且尚未落到其它可点击
// 条目上(例如掠过父项右缘与子菜单之间的内边距窄带、分隔线或禁用项),就不收起子菜单。
// 这样可避免处理器速度较快、鼠标移动事件过密时,用户还没把指针移到子菜单上,
// 子菜单就被中途误关闭;只有明确移到其它条目或完全离开该范围时才收起。
if (m_subOpen && !onSubmenuParent && index < 0 &&
(UiPointInRect(m_rect, fx, fy) || UiPointInRect(m_subRect, fx, fy)))
{
// 保持在父项高亮,视觉上指明子菜单的来源。
if (m_hoverIndex != m_subParentIndex || m_hoverSwatch != -1)
{
m_hoverIndex = m_subParentIndex;
m_hoverSwatch = -1;
changed = true;
}
return changed;
}
if (index != m_hoverIndex || swatchIndex != m_hoverSwatch) if (index != m_hoverIndex || swatchIndex != m_hoverSwatch)
{ {
@@ -702,9 +723,7 @@ namespace jdi
changed = true; changed = true;
} }
// 悬停到带子菜单的项 → 向右展开;移开到其它条目 → 收起。 // 悬停到带子菜单的项 → 向右展开;移开到其它条目或离开菜单 → 收起。
const bool onSubmenuParent =
(index >= 0) && (m_items[static_cast<std::size_t>(index)].kind == MenuItemKind::SUBMENU);
if (onSubmenuParent) if (onSubmenuParent)
{ {
if (!m_subOpen || m_subParentIndex != index) if (!m_subOpen || m_subParentIndex != index)