网格菜单重构、大文件异步加载与内存/解析优化、修复应用图标

网格右键菜单:
- 菜单顺序改为: 网格 N / Y 轴最大值 / Y 轴最小值 / 选区 Y 轴自适应 / --分隔线-- /
  Y 轴自适应 / 时间轴自适应 / 全部自适应
  (恢复自适应移到分隔线以下、时间轴自适应上方)
- Y 轴最大值/最小值改为菜单内联输入框:点击就地编辑、回车提交,菜单不关闭,
  处理方式与拾色器的 RGB 内联编辑一致;提交后按真实夹取结果回写菜单显示值,
  并同步刷新自适应项的文案/对勾/可用状态
- 新增"选区 Y 轴自适应":只按当前显示时间段内的数据做幅值自适应
  (时间单调时二分定位区间,非单调时全扫并按区间过滤)

大文件加载:
- 加载改为工作线程执行(解析 + 按列重组),主线程抽送消息并播放过渡动画
  (旋转弧线 + 进度条 + 文件名 + 百分比 + 按 Esc 取消),界面不再整块卡死
- 加载完成后主线程只做一次 O(1) 的移动入队,不会产生可见卡顿
- 新增 DataModel::BuildDataFile / DataSet::AddPreparedFile:不依赖 DataSet,
  因此可以安全地在线程上运行

内存与解析效率:
- CsvTable 由"行优先二维数组"改为"列优先":百万行文件原先会产生上百万次独立堆分配
  (每行还有 vector 控制块与容量对齐开销),现在每列一整块连续内存
- 解析改为流式分块读取(4MB),不再把整个文件读入内存,也没有体积上限
  (原先超过 512MB 直接拒绝);实测 51MB 文件峰值内存约 126MB(数据本身 113MB)
- BuildDataFile 直接把整列移动进 SignalData(零拷贝),不再逐元素复制
- TryParseDouble 增加十进制快速路径:ucrt strtod 实测约 335ns/次,手写路径约 13ns
  (快 25 倍);非十进制写法(十六进制浮点、inf/nan、带尾部杂字符)原样交回 strtod,
  输入接受语义完全不变(实测与 strtod 的差异不超过 1 ULP)
  → 640MB 测试文件加载时间由约 40s 降到 2s 以内

交互流畅性:
- 拖拽(平移/框选/拖动信号)期间关闭波形抗锯齿并压缩顶点预算(2 像素一列包络),
  松开后立即恢复全质量绘制;实测 BIG_DATA 10 信号全显示的帧耗时由
  105~168ms 降到 75~90ms(其中折线绘制 55ms→23ms)
- 折线包络抽稀改为"列边界单调推进"的二分,避免每列在全区间二分造成缓存缺失
- 未对波形构建引入多线程:构建已由几何键缓存消除,且拖拽降级后仅约 1~2ms/帧,
  并行化收益不足以抵消线程与同步的复杂度(加载路径的多线程收益明确,故只在该处引入)

图标:
- 窗口类与 WM_SETICON 原先使用系统默认图标 IDI_APPLICATION,导致标题栏、
  任务栏与 Alt+Tab 缩略图都显示成通用应用图标;改为按 SM_CXICON/SM_CXSMICON
  从应用的 IDI_APP_ICON 资源加载(多尺寸 ICO 会挑选最接近的一帧)

其他:
- PopupMenu 新增 EDIT 项类型(菜单内联输入框)与 UpdateItemValue/UpdateItemLabel
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-09-30 18:46:56 +08:00
1 parent d6a81839bb
commit 45b44eae57
11 files changed
+1637 -222

No files matched your search

@@ -202,6 +202,12 @@ namespace jdi
*/
void ShowPlotContextMenu(int laneIndex, int x, int y);
/**
* @brief 把网格菜单上的 Y 轴上下限显示值刷新为数据层的真实值(菜单保持打开)。
* @param [IN] laneIndex 网格下标。
*/
void RefreshPlotMenuValues(int laneIndex);
/**
* @brief 派发弹出菜单的选择结果。
* @param [IN] result 菜单选择结果。
@@ -264,6 +270,28 @@ namespace jdi
*/
void LoadCommandLineFiles();
/**
* @brief 在后台线程解析并构建数据文件,同时播放加载过渡动画(界面不卡死)。
*
* 大文件的瓶颈在“解析 CSV”与“按列重组采样数据”,这两步都不依赖 DataSet,
* 因此放在工作线程执行;主线程循环抽送消息并重绘加载遮罩层,
* 直到后台完成后再把结果移动进 DataSet(O(1),不会引起卡顿)。
*
* @param [IN] path 文件路径。
* @param [OUT] file 构建好的数据文件。
* @param [OUT] error 失败原因描述。
* @return 加载成功返回 true;用户取消或失败返回 false。
*/
bool LoadFileAsync(const std::wstring& path, DataFile& file, std::wstring& error);
/**
* @brief 绘制加载过渡动画遮罩(在 Render 的最后一步调用,覆盖整个客户区)。
* @param [IN,OUT] g 绘图上下文。
* @param [IN] width 客户区宽度。
* @param [IN] height 客户区高度。
*/
void PaintLoadingOverlay(gd::Graphics& g, int width, int height);
/**
* @brief 处理客户区鼠标移动。
* @param [IN] x 客户区横坐标。
@@ -317,6 +345,12 @@ namespace jdi
int m_cachedVisibleCount = -1; /**< 上次同步时的可见信号数量。 */
std::size_t m_cachedLaneCount = 0; /**< 上次同步时的网格数量。 */
bool m_cachedAnyVisible = false; /**< 上次同步时是否存在可见信号。 */
bool m_loading = false; /**< 是否正在显示加载过渡动画。 */
std::wstring m_loadingText; /**< 加载遮罩显示的文件名。 */
float m_loadingProgress = 0.0f; /**< 加载进度(0-1)。 */
float m_loadingSpin = 0.0f; /**< 加载动画旋转相位(度)。 */
unsigned long long m_loadingBytes = 0; /**< 当前文件字节数(用于展示体积)。 */
};
} // namespace jdi
+369 -32
View File
@@ -1,13 +1,16 @@
#include "Application.hpp"
#include <algorithm>
#include <atomic>
#include <cmath>
#include <thread>
#include <commdlg.h>
#include <shellapi.h>
#include <windowsx.h>
#include "CsvTable.hpp"
#include "ResourceIds.h"
#include "Utf8Util.hpp"
#ifndef WM_DPICHANGED
@@ -37,6 +40,87 @@ namespace jdi
const int kMenuTabClose = 2;
const int kMenuTabCloseOthers = 3;
/**
* @brief 从应用资源中按指定尺寸加载图标。
* @param [IN] cx 期望宽度(像素)。
* @param [IN] cy 期望高度(像素)。
* @return 图标句柄,加载失败返回 nullptr。
*/
HICON LoadAppIcon(int cx, int cy)
{
HINSTANCE module = GetModuleHandleW(nullptr);
// LR_DEFAULTSIZE 之外的显式尺寸请求会挑选 ICO 中最接近的一帧并由系统缩放,
// 因此 256/64/48/32/16 的多尺寸资源在任意 DPI 下都能取到合适的一帧。
HICON icon = static_cast<HICON>(LoadImageW(module, MAKEINTRESOURCEW(IDI_APP_ICON), IMAGE_ICON,
cx, cy, LR_DEFAULTCOLOR));
if (icon == nullptr)
{
icon = static_cast<HICON>(LoadImageW(module, MAKEINTRESOURCEW(IDI_APP_ICON), IMAGE_ICON, 0, 0,
LR_DEFAULTCOLOR | LR_DEFAULTSIZE));
}
return icon;
}
/**
* @brief 后台加载任务的共享状态。
*
* `done` 是唯一的同步点:工作线程写完其它字段后以 release 语义置位,
* 主线程以 acquire 语义读取,因此无需额外锁。
*/
struct AsyncLoadState
{
std::atomic<bool> done{false}; /**< 工作线程是否已结束。 */
std::atomic<bool> cancelled{false}; /**< 是否请求取消。 */
std::atomic<float> progress{0.0f}; /**< 0-1 进度。 */
bool ok = false; /**< 加载是否成功。 */
std::wstring error; /**< 失败原因。 */
DataFile file; /**< 构建结果。 */
};
/**
* @brief 工作线程主体:解析 CSV 并构建数据文件(不接触 DataSet)。
* @param [IN,OUT] state 共享状态。
* @param [IN] path 文件路径。
* @param [IN] paletteBase 调色板起始序号。
*/
void RunAsyncLoad(AsyncLoadState* state, std::wstring path, std::size_t paletteBase)
{
// 解析阶段占总进度的 0-75%,列重组与统计占 75-100%。
CsvProgress parseProgress = [state](double ratio)
{
state->progress.store(static_cast<float>(ratio * 0.75), std::memory_order_relaxed);
return !state->cancelled.load(std::memory_order_relaxed);
};
CsvTable table;
std::wstring error;
if (!LoadCsvTable(path, table, error, parseProgress))
{
state->error = error;
state->done.store(true, std::memory_order_release);
return;
}
CsvProgress buildProgress = [state](double ratio)
{
state->progress.store(static_cast<float>(0.75 + ratio * 0.25), std::memory_order_relaxed);
return !state->cancelled.load(std::memory_order_relaxed);
};
DataFile file;
if (!BuildDataFile(std::move(table), path, paletteBase, file, error, buildProgress))
{
state->error = error;
state->done.store(true, std::memory_order_release);
return;
}
state->file = std::move(file);
state->ok = true;
state->progress.store(1.0f, std::memory_order_relaxed);
state->done.store(true, std::memory_order_release);
}
/**
* @brief 开启进程级 DPI 感知(优先 Per-Monitor V2)。
*/
@@ -159,8 +243,10 @@ namespace jdi
windowClass.hCursor = LoadCursorW(nullptr, IDC_ARROW);
windowClass.hbrBackground = nullptr;
windowClass.lpszClassName = kWindowClassName;
windowClass.hIcon = LoadIconW(nullptr, IDI_APPLICATION);
windowClass.hIconSm = windowClass.hIcon;
// 使用应用自己的图标资源(IDI_APP_ICON);
// 原先取 IDI_APPLICATION 是系统默认图标,导致标题栏与任务栏缩略图显示成"通用应用"图标。
windowClass.hIcon = LoadAppIcon(GetSystemMetrics(SM_CXICON), GetSystemMetrics(SM_CYICON));
windowClass.hIconSm = LoadAppIcon(GetSystemMetrics(SM_CXSMICON), GetSystemMetrics(SM_CYSMICON));
if (RegisterClassExW(&windowClass) == 0)
{
@@ -204,10 +290,19 @@ namespace jdi
m_plot.Setup(&m_fonts, &m_theme);
m_plot.SetScale(m_scale);
m_plot.SetHostWindow(m_hwnd);
m_menu.SetHostWindow(m_hwnd);
HICON icon = LoadIconW(nullptr, IDI_APPLICATION);
SendMessageW(m_hwnd, WM_SETICON, ICON_BIG, reinterpret_cast<LPARAM>(icon));
SendMessageW(m_hwnd, WM_SETICON, ICON_SMALL, reinterpret_cast<LPARAM>(icon));
// 标题栏小图标与任务栏/Alt+Tab 缩略图大图标分开设置(同一个多尺寸 ICO 资源的不同尺寸)。
HICON bigIcon = LoadAppIcon(GetSystemMetrics(SM_CXICON), GetSystemMetrics(SM_CYICON));
HICON smallIcon = LoadAppIcon(GetSystemMetrics(SM_CXSMICON), GetSystemMetrics(SM_CYSMICON));
if (bigIcon != nullptr)
{
SendMessageW(m_hwnd, WM_SETICON, ICON_BIG, reinterpret_cast<LPARAM>(bigIcon));
}
if (smallIcon != nullptr)
{
SendMessageW(m_hwnd, WM_SETICON, ICON_SMALL, reinterpret_cast<LPARAM>(smallIcon));
}
// 创建首个标签页。
NewTab();
@@ -607,6 +702,9 @@ namespace jdi
// 弹出菜单与浮动拾色器始终绘制在最上层,保证与主界面风格一致。
m_menu.Paint(g);
m_colorPicker.Paint(g);
// 加载遮罩层在一切内容之上(包括菜单与拾色器),形成模态观感。
PaintLoadingOverlay(g, width, height);
}
HDC windowDc = GetDC(m_hwnd);
@@ -694,21 +792,24 @@ namespace jdi
return false;
}
// 同一文件允许多次加载:每次都会新建一个独立分组,各自持有独立的采样数据副本,
// 因此后续对原始文件的修改或删除都不会影响已加载内容。
CsvTable table;
// 大文件在工作线程解析并构建,主线程播放加载过渡动画,界面不再整块卡住。
DataFile file;
std::wstring error;
if (!LoadCsvTable(path, table, error))
if (!LoadFileAsync(path, file, error))
{
SetStatusMessage(L"解析失败:" + error, true);
if (!error.empty())
{
SetStatusMessage(L"解析失败:" + error, true);
}
InvalidateRect(m_hwnd, nullptr, FALSE);
return false;
}
int fileIndex = -1;
if (!m_dataSet.AddFromTable(table, path, error, fileIndex))
// 此处只做一次指针/缓冲移动,不拷贝任何采样数据,因此不会造成可见卡顿。
if (!m_dataSet.AddPreparedFile(std::move(file), fileIndex))
{
SetStatusMessage(L"解析失败:" + error, true);
SetStatusMessage(L"解析失败:数据为空。", true);
InvalidateRect(m_hwnd, nullptr, FALSE);
return false;
}
@@ -717,14 +818,14 @@ namespace jdi
SyncTabsToDataSet();
UpdateWindowTitle();
const DataFile* file = m_dataSet.FileAt(fileIndex);
if (file != nullptr)
const DataFile* loaded = m_dataSet.FileAt(fileIndex);
if (loaded != nullptr)
{
std::wstring message = L"已加载 组" + std::to_wstring(fileIndex + 1) + L"|" + file->Title() +
L"(" + FormatCount(static_cast<unsigned long long>(file->time.size())) +
L" 行采样," + std::to_wstring(file->signals.size()) +
L" 条信号,时间列:" + file->timeColumnName + L")";
if (!file->timeMonotonic)
std::wstring message = L"已加载 组" + std::to_wstring(fileIndex + 1) + L"|" + loaded->Title() +
L"(" + FormatCount(static_cast<unsigned long long>(loaded->time.size())) +
L" 行采样," + std::to_wstring(loaded->signals.size()) +
L" 条信号,时间列:" + loaded->timeColumnName + L")";
if (!loaded->timeMonotonic)
{
message += L" 提示:时间列非单调递增,光标取值使用最近邻策略。";
}
@@ -733,6 +834,148 @@ namespace jdi
return true;
}
bool Application::LoadFileAsync(const std::wstring& path, DataFile& file, std::wstring& error)
{
error.clear();
AsyncLoadState state;
const std::size_t paletteBase = m_dataSet.SignalCount();
m_loading = true;
m_loadingProgress = 0.0f;
const std::size_t slashPos = path.find_last_of(L"\\/");
m_loadingText = (slashPos == std::wstring::npos) ? path : path.substr(slashPos + 1);
std::thread worker(RunAsyncLoad, &state, path, paletteBase);
// 主线程循环:抽送消息 + 刷新动画,直到后台完成或用户取消。
while (!state.done.load(std::memory_order_acquire))
{
MSG message = {};
while (PeekMessageW(&message, nullptr, 0, 0, PM_REMOVE))
{
if (message.message == WM_QUIT)
{
// 退出请求:让后台停下并重新投递 WM_QUIT,由主循环正常收尾。
state.cancelled.store(true, std::memory_order_relaxed);
PostQuitMessage(static_cast<int>(message.wParam));
break;
}
// 加载期间屏蔽鼠标与其它按键,避免重入加载或误改数据;
// 保留 Esc 作为取消入口。
if (message.message >= WM_MOUSEFIRST && message.message <= WM_MOUSELAST)
{
continue;
}
if (message.message == WM_KEYDOWN && message.wParam == VK_ESCAPE)
{
state.cancelled.store(true, std::memory_order_relaxed);
continue;
}
if (message.message >= WM_KEYFIRST && message.message <= WM_KEYLAST)
{
continue;
}
TranslateMessage(&message);
DispatchMessageW(&message);
}
m_loadingProgress = state.progress.load(std::memory_order_relaxed);
InvalidateRect(m_hwnd, nullptr, FALSE);
UpdateWindow(m_hwnd);
Sleep(15);
}
m_loadingProgress = 1.0f;
worker.join();
m_loading = false;
if (state.cancelled.load(std::memory_order_relaxed))
{
// 用户主动取消:不弹错误框(与 Esc 取消缩放选区的静默语义一致)。
InvalidateRect(m_hwnd, nullptr, TRUE);
return false;
}
if (!state.ok)
{
error = state.error;
return false;
}
file = std::move(state.file);
return true;
}
void Application::PaintLoadingOverlay(gd::Graphics& g, int width, int height)
{
if (!m_loading)
{
return;
}
// 半透明遮罩压暗整个界面,形成明确的"正在加载"状态。
UiFillRect(g, gd::RectF(0.0f, 0.0f, static_cast<float>(width), static_cast<float>(height)),
0x66000000u);
const float cardWidth = std::min(420.0f * m_scale, static_cast<float>(width) - 40.0f * m_scale);
const float cardHeight = 132.0f * m_scale;
const gd::RectF card((static_cast<float>(width) - cardWidth) * 0.5f,
(static_cast<float>(height) - cardHeight) * 0.5f, cardWidth, cardHeight);
UiFillRoundRect(g, card, 8.0f * m_scale, m_theme.cardBg);
UiDrawRoundRect(g, card, 8.0f * m_scale, m_theme.borderStrong, 1.0f);
// 旋转弧线:用时间驱动的起始角度形成持续的加载动画。
const float spinnerRadius = 11.0f * m_scale;
const gd::RectF spinner(card.X + 26.0f * m_scale, card.Y + 30.0f * m_scale - spinnerRadius,
spinnerRadius * 2.0f, spinnerRadius * 2.0f);
m_loadingSpin += 12.0f;
if (m_loadingSpin >= 360.0f)
{
m_loadingSpin -= 360.0f;
}
gd::Pen trackPen(ToGdiColor(m_theme.border), 3.0f * m_scale);
gd::Pen spinPen(ToGdiColor(m_theme.accent), 3.0f * m_scale);
g.DrawArc(&trackPen, spinner, 0.0f, 359.0f);
g.DrawArc(&spinPen, spinner, m_loadingSpin, 110.0f);
const float textLeft = card.X + 52.0f * m_scale;
const float textWidth = cardWidth - 52.0f * m_scale - 20.0f * m_scale;
UiText(g, L"正在加载数据…", m_fonts.uiBold,
gd::RectF(textLeft, card.Y + 18.0f * m_scale, textWidth, 22.0f * m_scale), m_theme.text,
TextAlign::LEFT);
// 超长文件名用省略号截断,避免撑破卡片。
const std::wstring nameText =
UiEllipsize(g, m_loadingText, m_fonts.uiSmall, textWidth);
UiText(g, nameText, m_fonts.uiSmall,
gd::RectF(textLeft, card.Y + 42.0f * m_scale, textWidth, 20.0f * m_scale),
m_theme.textMuted, TextAlign::LEFT);
// 进度条(复用样式区滑块以外的简单圆角矩形,填充宽度严格按比例)。
const gd::RectF track(card.X + 26.0f * m_scale, card.Y + 76.0f * m_scale,
cardWidth - 52.0f * m_scale, 8.0f * m_scale);
UiFillRoundRect(g, track, track.Height * 0.5f, m_theme.pressed);
float ratio = m_loadingProgress;
ratio = std::max(0.0f, std::min(ratio, 1.0f));
const float fillWidth = track.Width * ratio;
if (fillWidth > 1.0f)
{
UiFillRoundRect(g, gd::RectF(track.X, track.Y, fillWidth, track.Height), track.Height * 0.5f,
m_theme.accent);
}
wchar_t buffer[64];
swprintf(buffer, sizeof(buffer) / sizeof(buffer[0]), L"%d%%", static_cast<int>(ratio * 100.0f + 0.5f));
UiText(g, buffer, m_fonts.monoSmall,
gd::RectF(track.X, card.Y + 92.0f * m_scale, track.Width, 20.0f * m_scale), m_theme.text,
TextAlign::LEFT);
UiText(g, L"按 Esc 取消", m_fonts.uiSmall,
gd::RectF(track.X, card.Y + 92.0f * m_scale, track.Width, 20.0f * m_scale),
m_theme.textMuted, TextAlign::RIGHT);
}
void Application::LoadCommandLineFiles()
{
int argumentCount = 0;
@@ -1127,21 +1370,38 @@ namespace jdi
const bool isAuto = m_plot.LaneRangeIsAuto(laneIndex);
// 当前网格的 Y 轴上下限值直接在菜单项中显示,单击即可直接改写。
// 当前网格的 Y 轴上下限值直接在菜单内的输入框里显示,单击即可就地编辑(菜单不关闭)。
double laneYMin = 0.0;
double laneYMax = 1.0;
m_plot.LaneRange(laneIndex, laneYMin, laneYMax);
MenuItem maxItem;
maxItem.label = L"Y 轴最大值:" + FormatValue(laneYMax);
maxItem.kind = MenuItemKind::EDIT;
maxItem.label = L"Y 轴最大值";
maxItem.id = PLOT_CMD_LANE_Y_MAX;
maxItem.value = FormatValue(laneYMax);
maxItem.filter = EditFilter::NUMBER;
items.push_back(maxItem);
MenuItem minItem;
minItem.label = L"Y 轴最小值:" + FormatValue(laneYMin);
minItem.kind = MenuItemKind::EDIT;
minItem.label = L"Y 轴最小值";
minItem.id = PLOT_CMD_LANE_Y_MIN;
minItem.value = FormatValue(laneYMin);
minItem.filter = EditFilter::NUMBER;
items.push_back(minItem);
// 选区自适应:只按当前显示时间段内的数据做幅值自适应(放大局部后铺满网格)。
MenuItem autoViewItem;
autoViewItem.label = L"选区 Y 轴自适应";
autoViewItem.id = PLOT_CMD_LANE_AUTO_VIEW;
items.push_back(autoViewItem);
MenuItem separator;
separator.kind = MenuItemKind::SEPARATOR;
items.push_back(separator);
// 恢复自适应放在分隔线下方、时间轴自适应上方。
MenuItem autoItem;
autoItem.label = isAuto ? L"Y 轴自适应(当前)" : L"恢复 Y 轴自适应";
autoItem.id = PLOT_CMD_LANE_AUTO;
@@ -1149,10 +1409,6 @@ namespace jdi
autoItem.enabled = !isAuto;
items.push_back(autoItem);
MenuItem separator;
separator.kind = MenuItemKind::SEPARATOR;
items.push_back(separator);
MenuItem fitLaneTime;
fitLaneTime.label = L"时间轴自适应(匹配本网格)";
fitLaneTime.id = PLOT_CMD_LANE_FIT_TIME;
@@ -1171,6 +1427,25 @@ namespace jdi
m_menu.Open(MenuScope::PLOT_VIEW, bounds, x, y, items);
}
void Application::RefreshPlotMenuValues(int laneIndex)
{
if (laneIndex < 0)
{
return;
}
double laneYMin = 0.0;
double laneYMax = 1.0;
m_plot.LaneRange(laneIndex, laneYMin, laneYMax);
m_menu.UpdateItemValue(PLOT_CMD_LANE_Y_MAX, FormatValue(laneYMax));
m_menu.UpdateItemValue(PLOT_CMD_LANE_Y_MIN, FormatValue(laneYMin));
// 手工输入上下限后已不再是自动自适应,同步刷新该项的文字/选中标记/可用状态。
const bool isAuto = m_plot.LaneRangeIsAuto(laneIndex);
m_menu.UpdateItemLabel(PLOT_CMD_LANE_AUTO, isAuto ? L"Y 轴自适应(当前)" : L"恢复 Y 轴自适应",
isAuto, !isAuto);
}
void Application::DispatchMenuResult(const MenuResult& result)
{
if (!result.valid)
@@ -1192,6 +1467,21 @@ namespace jdi
}
case MenuScope::PLOT_VIEW:
{
// 菜单内联输入框的提交:就地更新网格 Y 轴上下限,菜单保持打开,
// 并把夹取后的真实值回写到菜单上(输入值可能被合法区间修正)。
if (result.editCommit)
{
const int laneIndex = m_plotMenuLane;
if (laneIndex >= 0)
{
m_plot.ApplyLaneValueText(laneIndex, result.id == PLOT_CMD_LANE_Y_MAX, result.editText);
RefreshPlotMenuValues(laneIndex);
}
SetFocus(m_hwnd);
InvalidateRect(m_hwnd, nullptr, FALSE);
break;
}
const int laneIndex = m_plotMenuLane;
m_plotMenuLane = -1;
switch (result.id)
@@ -1199,11 +1489,8 @@ namespace jdi
case PLOT_CMD_LANE_AUTO:
m_plot.ResetLaneRange(laneIndex);
break;
case PLOT_CMD_LANE_Y_MAX:
m_plot.BeginLaneValueEdit(laneIndex, true, result.itemRect);
break;
case PLOT_CMD_LANE_Y_MIN:
m_plot.BeginLaneValueEdit(laneIndex, false, result.itemRect);
case PLOT_CMD_LANE_AUTO_VIEW:
m_plot.AutoRangeLaneToView(laneIndex);
break;
case PLOT_CMD_LANE_FIT_TIME:
m_plot.FitTimeToLane(laneIndex);
@@ -1267,6 +1554,15 @@ namespace jdi
switch (message)
{
case WM_MOUSEMOVE:
// 菜单内联输入框拖拽框选优先(不改变悬停项,避免选区被中途清掉)。
if (m_menu.IsEditing() && m_activeTarget == Target::EDIT_FIELD)
{
if (m_menu.OnEditMouseMove(GET_X_LPARAM(lParam)))
{
InvalidateRect(hwnd, nullptr, FALSE);
}
return 0;
}
if (m_menu.OnMouseMove(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)))
{
InvalidateRect(hwnd, nullptr, FALSE);
@@ -1274,19 +1570,60 @@ namespace jdi
return 0;
case WM_LBUTTONDOWN:
{
const int downX = GET_X_LPARAM(lParam);
const int downY = GET_Y_LPARAM(lParam);
// 点击菜单内联输入框:定位插入符并开始拖拽框选(与常规输入框一致)。
if (m_menu.IsOnEditField(downX, downY))
{
m_menu.OnEditMouseDown(downX, downY);
SetCapture(hwnd);
m_activeTarget = Target::EDIT_FIELD;
InvalidateRect(hwnd, nullptr, FALSE);
return 0;
}
MenuResult result;
m_menu.OnLButtonDown(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam), result);
m_menu.OnLButtonDown(downX, downY, result);
InvalidateRect(hwnd, nullptr, FALSE);
// 点击其它位置会先提交正在编辑的数值,需要立即落到数据层并刷新菜单显示。
if (result.valid)
{
DispatchMenuResult(result);
}
return 0;
}
case WM_LBUTTONUP:
{
if (m_menu.IsEditing() && m_activeTarget == Target::EDIT_FIELD)
{
ReleaseCapture();
m_activeTarget = Target::NONE;
m_menu.OnEditMouseUp();
InvalidateRect(hwnd, nullptr, FALSE);
return 0;
}
MenuResult result;
m_menu.OnLButtonUp(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam), result);
InvalidateRect(hwnd, nullptr, FALSE);
DispatchMenuResult(result);
return 0;
}
case WM_CHAR:
{
// 菜单内联输入框的字符输入(数值/名称),回车在此提交。
MenuResult result;
if (m_menu.OnChar(static_cast<wchar_t>(wParam), result))
{
InvalidateRect(hwnd, nullptr, FALSE);
}
if (result.valid)
{
DispatchMenuResult(result);
}
return 0;
}
case WM_RBUTTONDOWN:
case WM_MBUTTONDOWN:
case WM_MOUSEWHEEL:
+31 -7
View File
@@ -47,11 +47,12 @@ namespace jdi
*/
enum PlotMenuCommand : int
{
PLOT_CMD_LANE_AUTO = 1, /**< 该网格恢复 Y 轴自适应。 */
PLOT_CMD_LANE_Y_MAX = 2, /**< 输入该网格 Y 轴最大值。 */
PLOT_CMD_LANE_Y_MIN = 3, /**< 输入该网格 Y 轴最小值。 */
PLOT_CMD_FIT_ALL = 4, /**< 全部网格与时间轴自适应。 */
PLOT_CMD_LANE_FIT_TIME = 5 /**< 时间轴自适应到本网格信号范围。 */
PLOT_CMD_LANE_AUTO = 1, /**< 该网格恢复 Y 轴自适应。 */
PLOT_CMD_LANE_Y_MAX = 2, /**< 输入该网格 Y 轴最大值。 */
PLOT_CMD_LANE_Y_MIN = 3, /**< 输入该网格 Y 轴最小值。 */
PLOT_CMD_FIT_ALL = 4, /**< 全部网格与时间轴自适应。 */
PLOT_CMD_LANE_FIT_TIME = 5, /**< 时间轴自适应到本网格信号范围。 */
PLOT_CMD_LANE_AUTO_VIEW = 6 /**< 按当前显示时间段内的数据做 Y 轴自适应。 */
};
/**
@@ -158,6 +159,17 @@ namespace jdi
*/
bool FitTimeToLane(int laneIndex);
/**
* @brief 依据当前显示时间段内的数据对该网格做 Y 轴自适应(“选区 Y 轴自适应”)。
*
* 与普通自适应(用整段数据的极值)不同:这里只看时间轴当前可见区间内的采样点,
* 因此放大到某段局部波形后可以快速把该段的幅值铺满整个网格。
*
* @param [IN] laneIndex 网格下标。
* @return 调整成功返回 true(区间内无有效数据时返回 false)。
*/
bool AutoRangeLaneToView(int laneIndex);
/**
* @brief 把时间吸附到距今最近的数据采样点。
*
@@ -302,6 +314,15 @@ namespace jdi
*/
bool BeginLaneValueEdit(int laneIndex, bool editMax, const gd::RectF& anchorRect);
/**
* @brief 直接把文本解析为网格 Y 轴上限/下限并应用(供菜单内联输入框提交使用)。
* @param [IN] laneIndex 网格下标。
* @param [IN] editMax true 写上限,false 写下限。
* @param [IN] text 输入文本。
* @return 解析并应用成功返回 true。
*/
bool ApplyLaneValueText(int laneIndex, bool editMax, const std::wstring& text);
/**
* @brief 查询某网格当前的 Y 轴显示范围。
* @param [IN] laneIndex 网格下标。
@@ -602,10 +623,11 @@ namespace jdi
* @brief 构建单条信号在当前视图下的折线点序列。
* @param [IN] resolved 已解析的信号视图。
* @param [IN] lane 所属网格绘制信息。
* @param [IN] coarse 交互降级模式:限制每列扫描的采样点数,降低拖拽帧耗时。
* @param [OUT] points 输出点序列。
* @param [OUT] breaks 输出每段折线的起始下标(用于处理缺失值断线)。
*/
void BuildPolyline(const ResolvedSignal& resolved, const LaneVisual& lane,
void BuildPolyline(const ResolvedSignal& resolved, const LaneVisual& lane, bool coarse,
std::vector<gd::PointF>& points, std::vector<std::size_t>& breaks) const;
/**
@@ -776,6 +798,7 @@ namespace jdi
float laneHeight = 0.0f; /**< 网格高度。 */
double laneYMin = 0.0; /**< 网格幅值下界。 */
double laneYMax = 0.0; /**< 网格幅值上界。 */
bool coarse = false; /**< 是否为交互降级顶点(拖拽期间)。 */
std::vector<gd::PointF> points; /**< 折线顶点。 */
std::vector<std::size_t> breaks; /**< 每段折线在 points 中的起始下标。 */
};
@@ -785,10 +808,11 @@ namespace jdi
* @param [IN] ref 信号引用(缓存键的一部分)。
* @param [IN] resolved 已解析的信号。
* @param [IN] lane 网格绘制信息。
* @param [IN] coarse 是否使用交互降级顶点(全质量与降级结果分别缓存)。
* @return 缓存条目指针,无法构建时返回 nullptr。
*/
const PolylineCacheEntry* AcquirePolyline(const SignalRef& ref, const ResolvedSignal& resolved,
const LaneVisual& lane) const;
const LaneVisual& lane, bool coarse) const;
mutable std::deque<PolylineCacheEntry> m_polylineCache; /**< 折线顶点缓存(按几何键复用)。 */
+223 -13
View File
@@ -9,6 +9,7 @@
namespace jdi
{
namespace
{
const int kTimeTickTarget = 10; /**< 时间轴期望刻度数。 */
@@ -831,6 +832,109 @@ namespace jdi
return true;
}
bool PlotView::AutoRangeLaneToView(int laneIndex)
{
if (m_tab == nullptr || m_dataSet == nullptr)
{
return false;
}
const std::vector<Lane>& lanes = m_tab->Lanes();
if (laneIndex < 0 || laneIndex >= static_cast<int>(lanes.size()))
{
return false;
}
const double viewMin = Tab().ViewMin();
const double viewMax = Tab().ViewMax();
if (!(viewMax > viewMin))
{
return false;
}
// 只统计时间轴当前可见区间内的采样点,因此放大局部波形后可以快速把该段铺满网格。
double low = std::numeric_limits<double>::infinity();
double high = -std::numeric_limits<double>::infinity();
for (const SignalRef& ref : lanes[static_cast<std::size_t>(laneIndex)].signals)
{
if (!Tab().IsVisible(ref))
{
continue;
}
const SignalData* signal = m_dataSet->Resolve(ref);
const DataFile* file = m_dataSet->FileAt(ref.fileIndex);
if (signal == nullptr || file == nullptr)
{
continue;
}
const std::size_t count = std::min(file->time.size(), signal->values.size());
std::size_t begin = 0;
std::size_t end = count;
if (file->timeMonotonic)
{
// 时间单调时直接二分定位区间,百万级采样也只需扫过可见段。
begin = static_cast<std::size_t>(
std::lower_bound(file->time.begin(), file->time.end(), viewMin) - file->time.begin());
if (begin > 0)
{
--begin;
}
end = static_cast<std::size_t>(
std::lower_bound(file->time.begin(), file->time.end(), viewMax) - file->time.begin());
if (end < count)
{
++end;
}
}
for (std::size_t index = begin; index < end; ++index)
{
if (!file->timeMonotonic)
{
const double t = file->time[index];
if (!std::isfinite(t) || t < viewMin || t > viewMax)
{
continue;
}
}
const double value = signal->values[index];
if (std::isfinite(value))
{
low = std::min(low, value);
high = std::max(high, value);
}
}
}
if (!std::isfinite(low) || !std::isfinite(high))
{
return false;
}
// 与自动自适应保持一致的上下留白,避免波形顶到网格边界。
if (high > low)
{
const double pad = (high - low) * 0.05;
low -= pad;
high += pad;
}
else
{
const double pad = std::max(1.0e-9, std::fabs(low) * 0.05);
low -= pad;
high += pad;
}
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
lane.autoRange = false;
lane.yMin = low;
lane.yMax = high;
BuildLaneVisuals();
return true;
}
void PlotView::ScaleLaneRange(int laneIndex, double factor, double anchorValue)
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()))
@@ -904,6 +1008,68 @@ namespace jdi
FormatValue(current), anchorRect);
}
bool PlotView::ApplyLaneValueText(int laneIndex, bool editMax, const std::wstring& text)
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
if (m_lanes.empty())
{
BuildLaneVisuals();
}
if (laneIndex >= static_cast<int>(m_lanes.size()))
{
return false;
}
const std::wstring trimmed = Trim(text);
if (trimmed.empty())
{
return false;
}
wchar_t* stop = nullptr;
const double parsed = std::wcstod(trimmed.c_str(), &stop);
if (stop == trimmed.c_str() || !std::isfinite(parsed))
{
return false;
}
const LaneVisual& visual = m_lanes[static_cast<std::size_t>(laneIndex)];
const double span = visual.yMax - visual.yMin;
const double minimumGap = (span > 0.0) ? span * 0.01 : std::max(1.0e-9, std::fabs(parsed) * 0.01);
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
// 手工输入即视为取消自动自适应,与拖拽框选缩放的语义一致。
// 必须先把当前(自适应计算出的)显示范围固化到 lane 里,再只改动被编辑的一端,
// 否则另一端会回退到 Lane 的初始占位值,导致量程被莫名其妙地放大。
if (lane.autoRange)
{
lane.autoRange = false;
lane.yMin = visual.yMin;
lane.yMax = visual.yMax;
}
if (editMax)
{
lane.yMax = parsed;
if (!(lane.yMax > lane.yMin))
{
lane.yMin = lane.yMax - minimumGap;
}
}
else
{
lane.yMin = parsed;
if (!(lane.yMax > lane.yMin))
{
lane.yMax = lane.yMin + minimumGap;
}
}
BuildLaneVisuals();
return true;
}
bool PlotView::LaneRange(int laneIndex, double& yMin, double& yMax) const
{
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()))
@@ -961,7 +1127,7 @@ namespace jdi
// 复用绘制阶段的折线顶点:悬停检测通常与上一帧几何完全相同,
// 不再为“鼠标移动但命中结果未变”重复扫描全量采样点。
const PolylineCacheEntry* cached = AcquirePolyline(ref, resolved, lane);
const PolylineCacheEntry* cached = AcquirePolyline(ref, resolved, lane, false);
if (cached == nullptr)
{
continue;
@@ -1831,7 +1997,7 @@ namespace jdi
const PlotView::PolylineCacheEntry* PlotView::AcquirePolyline(const SignalRef& ref,
const ResolvedSignal& resolved,
const LaneVisual& lane) const
const LaneVisual& lane, bool coarse) const
{
if (resolved.data == nullptr || resolved.file == nullptr)
{
@@ -1851,7 +2017,7 @@ namespace jdi
entry.viewMin == viewMin && entry.viewMax == viewMax && entry.plotX == m_plotRect.X &&
entry.plotWidth == m_plotRect.Width && entry.laneY == lane.rect.Y &&
entry.laneHeight == lane.rect.Height && entry.laneYMin == lane.yMin &&
entry.laneYMax == lane.yMax)
entry.laneYMax == lane.yMax && entry.coarse == coarse)
{
return &entry;
}
@@ -1876,13 +2042,14 @@ namespace jdi
entry.laneHeight = lane.rect.Height;
entry.laneYMin = lane.yMin;
entry.laneYMax = lane.yMax;
entry.coarse = coarse;
BuildPolyline(resolved, lane, entry.points, entry.breaks);
BuildPolyline(resolved, lane, coarse, entry.points, entry.breaks);
// BuildPolyline 通过引用填充,push_back 过程中不会再次进入本函数,引用安全。
return &m_polylineCache.back();
}
void PlotView::BuildPolyline(const ResolvedSignal& resolved, const LaneVisual& lane,
void PlotView::BuildPolyline(const ResolvedSignal& resolved, const LaneVisual& lane, bool coarse,
std::vector<gd::PointF>& points, std::vector<std::size_t>& breaks) const
{
points.clear();
@@ -1936,7 +2103,9 @@ namespace jdi
// 顶点预算由绘图区宽度决定(而不是固定值):折线顶点数直接决定
// GDI+ 抗锯齿折线的绘制耗时,百万级采样文件若按固定 20000 顶点绘制,
// 会把大量顶点堆在同一个像素列上(画出来完全重合),纯粹浪费 CPU。
std::size_t budget = static_cast<std::size_t>(std::max(1.0f, m_plotRect.Width)) * kPolylinePointsPerPixel;
// 拖拽期间进一步降半:顶点数就是绘制耗时,交互时用“2 像素一列”的包络即可。
const std::size_t pointsPerPixel = coarse ? 1 : kPolylinePointsPerPixel;
std::size_t budget = static_cast<std::size_t>(std::max(1.0f, m_plotRect.Width)) * pointsPerPixel;
budget = std::max(budget, kMinPolylinePoints);
budget = std::min(budget, kMaxPolylinePoints);
@@ -1951,22 +2120,33 @@ namespace jdi
const std::size_t columns = std::max<std::size_t>(1, budget / 2);
const float columnWidth = plotWidth / static_cast<float>(columns);
const std::vector<double>::const_iterator rangeBegin = time.begin() + static_cast<std::ptrdiff_t>(begin);
const std::vector<double>::const_iterator rangeEnd = time.begin() + static_cast<std::ptrdiff_t>(end);
// 交互降级:拖拽期间限定每列扫描的采样点数。
// 全量扫描是 O(可见采样数),千万级采样 × 多条信号会让拖拽掉到 10FPS 以下;
// 每列取 32 个点已经足够看出包络形状,松手后会重新做一次全质量绘制。
const std::size_t coarsePointsPerColumn = 32;
bool inSegment = false;
std::size_t searchFrom = begin;
for (std::size_t column = 0; column < columns; ++column)
{
const double t0 = viewMin + viewSpan * (static_cast<double>(column) / static_cast<double>(columns));
const double t1 =
viewMin + viewSpan * (static_cast<double>(column + 1) / static_cast<double>(columns));
// 列边界随 column 单调递增,从上一列的位置继续二分,
// 把每次搜索的范围压到剩余区间,避免 2400 次全区间二分带来的缓存缺失。
const std::size_t i0 = static_cast<std::size_t>(
std::lower_bound(rangeBegin, rangeEnd, t0) - time.begin());
std::lower_bound(time.begin() + static_cast<std::ptrdiff_t>(searchFrom), rangeEnd, t0) -
time.begin());
searchFrom = i0;
const std::size_t i1 = (column + 1 == columns)
? end
: static_cast<std::size_t>(
std::lower_bound(rangeBegin, rangeEnd, t1) - time.begin());
std::lower_bound(time.begin() + static_cast<std::ptrdiff_t>(i0),
rangeEnd, t1) -
time.begin());
if (i1 <= i0)
{
// 该列没有采样点:说明数据在此处存在时间间隙,断开当前折线。
@@ -1974,18 +2154,31 @@ namespace jdi
continue;
}
std::size_t scanStep = 1;
if (coarse)
{
const std::size_t columnPoints = i1 - i0;
if (columnPoints > coarsePointsPerColumn)
{
scanStep = columnPoints / coarsePointsPerColumn;
}
}
double low = std::numeric_limits<double>::infinity();
double high = -std::numeric_limits<double>::infinity();
std::size_t lowIndex = 0;
std::size_t highIndex = 0;
bool broken = false;
for (std::size_t index = i0; index < i1; ++index)
for (std::size_t index = i0; index < i1; index += scanStep)
{
const double value = values[index];
if (!std::isfinite(value))
{
// 该列含无效值:包络绘制到本列结束,后续另起一段。
broken = true;
if (!coarse)
{
broken = true;
}
continue;
}
if (value < low)
@@ -2312,6 +2505,15 @@ namespace jdi
const int waveSave = g.Save();
g.SetClip(lane.rect, gd::CombineModeIntersect);
// 交互(拖拽平移/框选/拖动信号)期间关闭抗锯齿:波形折线是整帧中最贵的绘制调用,
// 关闭抗锯齿可显著降低单帧耗时,使拖拽跟手;松开后立即恢复抗锯齿全质量重绘。
const bool interactive = (m_dragMode != PlotDragMode::NONE);
const gd::SmoothingMode previousSmoothing = g.GetSmoothingMode();
if (interactive)
{
g.SetSmoothingMode(gd::SmoothingModeNone);
}
for (const SignalRef& ref : laneSignals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
@@ -2322,7 +2524,9 @@ namespace jdi
const DrawMode mode = resolved.style->mode;
// 顶点由“几何键”缓存提供:同一视图下重复绘制不再重算全量采样。
const PolylineCacheEntry* cached = AcquirePolyline(ref, resolved, lane);
// 拖拽期间用降级顶点(扫描点数受限),松手后自然回到全质量版本。
const bool coarse = interactive;
const PolylineCacheEntry* cached = AcquirePolyline(ref, resolved, lane, coarse);
if (cached == nullptr)
{
continue;
@@ -2392,6 +2596,12 @@ namespace jdi
}
}
// 交互期间临时关闭的抗锯齿在此恢复(g.Restore 也会还原状态,这里显式恢复以免依赖栈深)。
if (interactive)
{
g.SetSmoothingMode(previousSmoothing);
}
g.Restore(waveSave);
}
@@ -2937,7 +3147,7 @@ namespace jdi
std::vector<gd::PointF> points;
std::vector<std::size_t> breaks;
BuildPolyline(resolved, sourceVisual, points, breaks);
BuildPolyline(resolved, sourceVisual, false, points, breaks);
for (gd::PointF& point : points)
{