commit 132b305544bb8d1ca89ee4b1466ed858ac0b7099 Author: DeepSeek V4.1 Flash <> Date: Tue Sep 29 15:05:29 2026 +0800 feat: 搭建 Just DataInspector 波形数据查看器基础功能 基于 Win32 API + GDI+ 从零实现仿真数据检查器,用于替代 Simulink.sdi.view 查看带时间轴的 CSV 数据。 - 新增 Utilities 层:UTF-8/宽字符与数值格式化工具、CSV 解析器(自动识别分隔符与引号)、 信号数据集模型(时间轴识别、信号名解析、取值采样)、Solarised Light 主题色板与绘图基础工具 - 新增 Toolbar 组件:打开 CSV、适应窗口、适应时间、放大、缩小、合并网格、每信号一格 - 新增 SignalPanel 组件:信号列表(可见性开关、颜色标识、所属网格标记)、样式编辑区 (颜色、线宽、线段/散点/点线/阶梯显示方式、所属网格),支持右键上下文菜单与滚轮滚动 - 新增 PlotView 组件:多网格波形绘制、自适应时间轴与幅值轴、网格线、信号图例、 时间光标与实时数值读数、纵向滚动条 - 新增 Application 组件:DPI 感知(Per-Monitor V2)、双缓冲绘制、区域布局与分隔条拖拽、 鼠标与键盘消息分发、文件拖放、状态栏提示 - 交互能力:滚轮缩放、Shift+滚轮平移、Ctrl+滚轮缩放幅值、拖拽平移、幅值轴纵向缩放、 右键框选缩放、单击固定/取消时间光标、双击自适应、方向键平移与滚动 - 补齐 Makefile 的源文件与头文件索引,并新增 gdiplus、comdlg32 链接库 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7ce5eec --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# 构建产物 +Build/ + +# 临时脚本与截图 +.preflight/ + +# 常见二进制与中间产物 +*.o +*.d +*.lst +*.map +*.exe diff --git a/Components/Application/Inc/Application.hpp b/Components/Application/Inc/Application.hpp new file mode 100644 index 0000000..a454630 --- /dev/null +++ b/Components/Application/Inc/Application.hpp @@ -0,0 +1,215 @@ +#ifndef __APPLICATION_HPP__ +#define __APPLICATION_HPP__ + +#include +#include +#include + +#include + +#include "PaintUtil.hpp" +#include "PlotView.hpp" +#include "SignalPanel.hpp" +#include "SignalSet.hpp" +#include "Theme.hpp" +#include "Toolbar.hpp" + +namespace jdi +{ + + /** + * @brief 应用主框架:负责窗口、布局、消息分发、绘制合成与命令处理。 + */ + class Application + { + public: + /** + * @brief 获取全局唯一应用实例。 + * @return 应用实例引用。 + */ + static Application& Instance(); + + /** + * @brief 初始化环境、创建窗口并进入消息循环。 + * @param [IN] instance 模块实例句柄。 + * @param [IN] showCommand 窗口显示方式。 + * @return 进程退出码。 + */ + int Run(HINSTANCE instance, int showCommand); + + /** + * @brief 窗口过程中转处理函数。 + * @param [IN] hwnd 窗口句柄。 + * @param [IN] message 消息标识。 + * @param [IN] wParam 消息参数。 + * @param [IN] lParam 消息参数。 + * @return 消息处理结果。 + */ + LRESULT HandleMessage(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam); + + private: + /** + * @brief 鼠标交互的作用目标。 + */ + enum class Target : int + { + NONE = 0, /**< 无目标。 */ + TOOLBAR, /**< 工具栏。 */ + PANEL, /**< 信号面板。 */ + PLOT, /**< 波形绘图区。 */ + SPLITTER /**< 面板分隔条。 */ + }; + + Application() = default; + ~Application() = default; + Application(const Application&) = delete; + Application& operator=(const Application&) = delete; + + /** + * @brief 窗口过程静态回调。 + * @param [IN] hwnd 窗口句柄。 + * @param [IN] message 消息标识。 + * @param [IN] wParam 消息参数。 + * @param [IN] lParam 消息参数。 + * @return 消息处理结果。 + */ + static LRESULT CALLBACK WindowProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam); + + /** + * @brief 创建主窗口与相关资源。 + * @param [IN] instance 模块实例句柄。 + * @param [IN] showCommand 窗口显示方式。 + * @return 创建成功返回 true。 + */ + bool Initialize(HINSTANCE instance, int showCommand); + + /** + * @brief 释放窗口与绘图资源。 + */ + void Shutdown(); + + /** + * @brief 依据 DPI 重建字体与布局。 + * @param [IN] dpi 目标 DPI。 + */ + void ApplyDpi(UINT dpi); + + /** + * @brief 重新计算各区域的布局。 + */ + void Relayout(); + + /** + * @brief 创建或调整后备缓冲区尺寸。 + * @param [IN] width 客户区宽度。 + * @param [IN] height 客户区高度。 + */ + void EnsureBackBuffer(int width, int height); + + /** + * @brief 释放后备缓冲区。 + */ + void ReleaseBackBuffer(); + + /** + * @brief 完整重绘客户区并呈现到窗口。 + */ + void Render(); + + /** + * @brief 绘制底部状态栏。 + * @param [IN,OUT] g 绘图上下文。 + */ + void PaintStatusBar(gd::Graphics& g); + + /** + * @brief 弹出文件选择对话框并加载所选文件。 + */ + void OpenFileDialog(); + + /** + * @brief 加载指定 CSV 文件并刷新界面。 + * @param [IN] path 文件完整路径。 + * @return 加载成功返回 true。 + */ + bool LoadFile(const std::wstring& path); + + /** + * @brief 执行工具栏动作。 + * @param [IN] action 动作标识。 + */ + void ExecuteToolbarAction(ToolbarAction action); + + /** + * @brief 处理键盘按键。 + * @param [IN] key 虚拟键码。 + * @return 已处理返回 true。 + */ + bool HandleKeyDown(WPARAM key); + + /** + * @brief 设置状态栏提示信息。 + * @param [IN] text 提示文本。 + * @param [IN] isError 是否为错误提示。 + */ + void SetStatusMessage(const std::wstring& text, bool isError); + + /** + * @brief 刷新窗口标题。 + */ + void UpdateWindowTitle(); + + /** + * @brief 解析命令行中的文件参数并加载。 + */ + void LoadCommandLineFile(); + + /** + * @brief 处理客户区鼠标移动。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @param [IN] buttonDown 是否有按键处于按下状态。 + * @return 需要重绘返回 true。 + */ + bool HandleMouseMove(int x, int y, bool buttonDown); + + HINSTANCE m_instance = nullptr; /**< 模块实例句柄。 */ + HWND m_hwnd = nullptr; /**< 主窗口句柄。 */ + UINT m_dpi = 96; /**< 当前 DPI。 */ + float m_scale = 1.0f; /**< DPI 缩放比例。 */ + ULONG_PTR m_gdiplusToken = 0; /**< GDI+ 运行时令牌。 */ + bool m_gdiplusReady = false; /**< GDI+ 是否初始化成功。 */ + + Theme m_theme; /**< 主题配色。 */ + UiFonts m_fonts; /**< 界面字体。 */ + SignalSet m_document; /**< 已加载的数据集。 */ + Toolbar m_toolbar; /**< 顶部工具栏。 */ + SignalPanel m_panel; /**< 左侧信号面板。 */ + PlotView m_plot; /**< 波形绘图区。 */ + + gd::RectF m_toolbarRect; /**< 工具栏区域。 */ + gd::RectF m_panelRect; /**< 信号面板区域。 */ + gd::RectF m_splitterRect; /**< 面板分隔条区域。 */ + gd::RectF m_plotRect; /**< 绘图区区域。 */ + gd::RectF m_statusRect; /**< 状态栏区域。 */ + + float m_panelWidth = 292.0f; /**< 面板宽度(逻辑像素)。 */ + bool m_draggingSplitter = false; /**< 是否正在拖动分隔条。 */ + int m_splitterGrabOffset = 0; /**< 分隔条拖动的抓取偏移。 */ + + HDC m_backDc = nullptr; /**< 后备缓冲设备上下文。 */ + HBITMAP m_backBitmap = nullptr; /**< 后备缓冲位图。 */ + HGDIOBJ m_backOldBitmap = nullptr; /**< 后备缓冲原始位图。 */ + int m_backWidth = 0; /**< 后备缓冲宽度。 */ + int m_backHeight = 0; /**< 后备缓冲高度。 */ + + Target m_activeTarget = Target::NONE; /**< 当前鼠标作用目标。 */ + bool m_trackingLeave = false; /**< 是否已注册鼠标离开通知。 */ + std::size_t m_lastLaneCount = 0; /**< 上次渲染时的网格数量,用于检测布局失效。 */ + std::wstring m_statusMessage; /**< 状态栏提示文本。 */ + bool m_statusIsError = false; /**< 状态提示是否为错误。 */ + }; + +} // namespace jdi + +#endif // __APPLICATION_HPP__ diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp new file mode 100644 index 0000000..63ecbcb --- /dev/null +++ b/Components/Application/Src/Application.cpp @@ -0,0 +1,987 @@ +#include "Application.hpp" + +#include +#include + +#include +#include +#include + +#include "CsvTable.hpp" +#include "Utf8Util.hpp" + +#ifndef WM_DPICHANGED +#define WM_DPICHANGED 0x02E0 +#endif + +#ifndef DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 +#define DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 (reinterpret_cast(-4)) +#endif + +namespace jdi +{ + + namespace + { + const wchar_t* kWindowClassName = L"JustDataInspectorMainWindow"; /**< 窗口类名。 */ + const wchar_t* kWindowTitle = L"Just DataInspector"; /**< 默认窗口标题。 */ + const float kStatusBarHeight = 26.0f; /**< 状态栏高度(逻辑像素)。 */ + const float kSplitterWidth = 4.0f; /**< 分隔条宽度(逻辑像素)。 */ + const float kMinPanelWidth = 210.0f; /**< 面板最小宽度(逻辑像素)。 */ + const float kMaxPanelWidth = 460.0f; /**< 面板最大宽度(逻辑像素)。 */ + const int kMinClientWidth = 820; /**< 窗口最小客户区宽度。 */ + const int kMinClientHeight = 520; /**< 窗口最小客户区高度。 */ + const wchar_t* kStatusHint = L"滚轮缩放 · Shift+滚轮平移 · Ctrl+滚轮调幅值 · 右键框选 · 双击自适应"; + + /** + * @brief 开启进程级 DPI 感知(优先 Per-Monitor V2)。 + */ + void EnableDpiAwareness() + { + HMODULE user32 = GetModuleHandleW(L"user32.dll"); + if (user32 != nullptr) + { + typedef BOOL(WINAPI * SetProcessDpiAwarenessContextFn)(HANDLE); + SetProcessDpiAwarenessContextFn setContext = + reinterpret_cast( + reinterpret_cast(GetProcAddress(user32, "SetProcessDpiAwarenessContext"))); + if (setContext != nullptr && + setContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) != FALSE) + { + return; + } + } + SetProcessDPIAware(); + } + + /** + * @brief 查询屏幕 DPI。 + * @return DPI 数值,失败时返回 96。 + */ + UINT QuerySystemDpi() + { + HDC hdc = GetDC(nullptr); + if (hdc == nullptr) + { + return 96u; + } + const int dpi = GetDeviceCaps(hdc, LOGPIXELSX); + ReleaseDC(nullptr, hdc); + return (dpi > 0) ? static_cast(dpi) : 96u; + } + + /** + * @brief 依据 DPI 缩放逻辑像素。 + * @param [IN] value 逻辑像素值。 + * @param [IN] scale 缩放比例。 + * @return 物理像素值。 + */ + float ScaleOf(float value, float scale) + { + return value * scale; + } + } + + Application& Application::Instance() + { + static Application instance; + return instance; + } + + LRESULT CALLBACK Application::WindowProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) + { + Application* self = reinterpret_cast(GetWindowLongPtrW(hwnd, GWLP_USERDATA)); + if (message == WM_NCCREATE) + { + CREATESTRUCTW* createStruct = reinterpret_cast(lParam); + self = reinterpret_cast(createStruct->lpCreateParams); + SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast(self)); + } + + if (self != nullptr) + { + return self->HandleMessage(hwnd, message, wParam, lParam); + } + return DefWindowProcW(hwnd, message, wParam, lParam); + } + + int Application::Run(HINSTANCE instance, int showCommand) + { + if (!Initialize(instance, showCommand)) + { + return 1; + } + + MSG message = {}; + while (GetMessageW(&message, nullptr, 0, 0) > 0) + { + TranslateMessage(&message); + DispatchMessageW(&message); + } + + Shutdown(); + return static_cast(message.wParam); + } + + bool Application::Initialize(HINSTANCE instance, int showCommand) + { + m_instance = instance; + + EnableDpiAwareness(); + m_dpi = QuerySystemDpi(); + m_scale = static_cast(m_dpi) / 96.0f; + + gd::GdiplusStartupInput startupInput; + if (gd::GdiplusStartup(&m_gdiplusToken, &startupInput, nullptr) != gd::Ok) + { + MessageBoxW(nullptr, L"GDI+ 初始化失败,程序无法启动。", kWindowTitle, MB_ICONERROR | MB_OK); + return false; + } + m_gdiplusReady = true; + + m_theme = SolarizedLight(); + if (!m_fonts.Create(m_dpi)) + { + m_gdiplusReady = false; + gd::GdiplusShutdown(m_gdiplusToken); + MessageBoxW(nullptr, L"界面字体创建失败,程序无法启动。", kWindowTitle, MB_ICONERROR | MB_OK); + return false; + } + + WNDCLASSEXW windowClass = {}; + windowClass.cbSize = sizeof(windowClass); + windowClass.style = CS_HREDRAW | CS_VREDRAW | CS_DBLCLKS; + windowClass.lpfnWndProc = &Application::WindowProc; + windowClass.hInstance = instance; + windowClass.hCursor = LoadCursorW(nullptr, IDC_ARROW); + windowClass.hbrBackground = nullptr; + windowClass.lpszClassName = kWindowClassName; + windowClass.hIcon = LoadIconW(nullptr, IDI_APPLICATION); + windowClass.hIconSm = windowClass.hIcon; + + if (RegisterClassExW(&windowClass) == 0) + { + MessageBoxW(nullptr, L"窗口类注册失败。", kWindowTitle, MB_ICONERROR | MB_OK); + return false; + } + + // 依据工作区(排除任务栏)计算默认窗口尺寸并居中,避免窗口超出屏幕。 + RECT workArea = {0, 0, static_cast(ScaleOf(1340.0f, m_scale)), + static_cast(ScaleOf(860.0f, m_scale))}; + SystemParametersInfoW(SPI_GETWORKAREA, 0, &workArea, 0); + + const int workWidth = workArea.right - workArea.left; + const int workHeight = workArea.bottom - workArea.top; + const int windowWidth = std::min(static_cast(ScaleOf(1340.0f, m_scale)), workWidth - 24); + const int windowHeight = std::min(static_cast(ScaleOf(860.0f, m_scale)), workHeight - 24); + const int windowX = workArea.left + std::max(0, (workWidth - windowWidth) / 2); + const int windowY = workArea.top + std::max(0, (workHeight - windowHeight) / 2); + + m_hwnd = CreateWindowExW(WS_EX_ACCEPTFILES, kWindowClassName, kWindowTitle, WS_OVERLAPPEDWINDOW, + windowX, windowY, windowWidth, windowHeight, nullptr, nullptr, instance, + this); + if (m_hwnd == nullptr) + { + MessageBoxW(nullptr, L"窗口创建失败。", kWindowTitle, MB_ICONERROR | MB_OK); + return false; + } + + m_toolbar.Setup(&m_fonts, &m_theme); + m_toolbar.SetScale(m_scale); + m_panel.Setup(m_hwnd, &m_fonts, &m_theme); + m_panel.SetScale(m_scale); + m_plot.Setup(&m_fonts, &m_theme); + m_plot.SetScale(m_scale); + + HICON icon = LoadIconW(nullptr, IDI_APPLICATION); + SendMessageW(m_hwnd, WM_SETICON, ICON_BIG, reinterpret_cast(icon)); + SendMessageW(m_hwnd, WM_SETICON, ICON_SMALL, reinterpret_cast(icon)); + + Relayout(); + ShowWindow(m_hwnd, showCommand); + UpdateWindow(m_hwnd); + LoadCommandLineFile(); + return true; + } + + void Application::Shutdown() + { + ReleaseBackBuffer(); + m_fonts.Destroy(); + + if (m_gdiplusReady) + { + gd::GdiplusShutdown(m_gdiplusToken); + m_gdiplusReady = false; + } + } + + void Application::ApplyDpi(UINT dpi) + { + if (dpi == 0) + { + return; + } + + m_dpi = dpi; + m_scale = static_cast(dpi) / 96.0f; + + m_fonts.Create(dpi); + m_toolbar.SetScale(m_scale); + m_panel.SetScale(m_scale); + m_plot.SetScale(m_scale); + ReleaseBackBuffer(); + Relayout(); + } + + void Application::Relayout() + { + RECT client = {}; + GetClientRect(m_hwnd, &client); + const float width = static_cast(client.right - client.left); + const float height = static_cast(client.bottom - client.top); + if (width <= 1.0f || height <= 1.0f) + { + return; + } + + const float toolbarHeight = m_toolbar.DesiredHeight(); + const float statusHeight = ScaleOf(kStatusBarHeight, m_scale); + const float splitterWidth = ScaleOf(kSplitterWidth, m_scale); + + m_toolbarRect = gd::RectF(0.0f, 0.0f, width, toolbarHeight); + m_statusRect = gd::RectF(0.0f, height - statusHeight, width, statusHeight); + + const float contentHeight = std::max(0.0f, height - toolbarHeight - statusHeight); + + float panelWidth = ScaleOf(m_panelWidth, m_scale); + const float maximum = std::min(ScaleOf(kMaxPanelWidth, m_scale), width * 0.45f); + panelWidth = std::max(ScaleOf(kMinPanelWidth, m_scale), std::min(panelWidth, maximum)); + m_panelWidth = panelWidth / m_scale; + + m_panelRect = gd::RectF(0.0f, toolbarHeight, panelWidth, contentHeight); + m_splitterRect = gd::RectF(panelWidth, toolbarHeight, splitterWidth, contentHeight); + m_plotRect = gd::RectF(panelWidth + splitterWidth, toolbarHeight, + std::max(0.0f, width - panelWidth - splitterWidth), contentHeight); + + m_toolbar.Layout(m_toolbarRect); + m_panel.Layout(m_panelRect); + m_plot.Layout(m_plotRect); + } + + void Application::EnsureBackBuffer(int width, int height) + { + if (m_backDc != nullptr && width == m_backWidth && height == m_backHeight) + { + return; + } + + ReleaseBackBuffer(); + + HDC screenDc = GetDC(m_hwnd); + if (screenDc == nullptr) + { + return; + } + m_backDc = CreateCompatibleDC(screenDc); + if (m_backDc != nullptr) + { + m_backBitmap = CreateCompatibleBitmap(screenDc, width, height); + if (m_backBitmap != nullptr) + { + m_backOldBitmap = SelectObject(m_backDc, m_backBitmap); + m_backWidth = width; + m_backHeight = height; + } + else + { + DeleteDC(m_backDc); + m_backDc = nullptr; + } + } + ReleaseDC(m_hwnd, screenDc); + } + + void Application::ReleaseBackBuffer() + { + if (m_backDc != nullptr) + { + if (m_backOldBitmap != nullptr) + { + SelectObject(m_backDc, m_backOldBitmap); + m_backOldBitmap = nullptr; + } + DeleteDC(m_backDc); + m_backDc = nullptr; + } + if (m_backBitmap != nullptr) + { + DeleteObject(m_backBitmap); + m_backBitmap = nullptr; + } + m_backWidth = 0; + m_backHeight = 0; + } + + void Application::Render() + { + RECT client = {}; + GetClientRect(m_hwnd, &client); + const int width = client.right - client.left; + const int height = client.bottom - client.top; + if (width <= 0 || height <= 0) + { + return; + } + + EnsureBackBuffer(width, height); + if (m_backDc == nullptr) + { + return; + } + + // 网格数量变化(如合并/拆分/移动信号)会影响滚动条与绘图区宽度,需要重新布局。 + const std::size_t laneCount = m_document.IsLoaded() ? m_document.Lanes().size() : 0; + if (laneCount != m_lastLaneCount) + { + m_lastLaneCount = laneCount; + Relayout(); + } + + m_toolbar.SetDocumentLoaded(m_document.IsLoaded()); + if (m_document.IsLoaded()) + { + m_toolbar.SetStatusText(L"视图 " + FormatValue(m_plot.ViewMinTime()) + L" ~ " + + FormatValue(m_plot.ViewMaxTime())); + } + else + { + m_toolbar.SetStatusText(std::wstring()); + } + + { + gd::Graphics g(m_backDc); + g.SetSmoothingMode(gd::SmoothingModeAntiAlias); + g.SetPixelOffsetMode(gd::PixelOffsetModeHalf); + g.SetTextRenderingHint(gd::TextRenderingHintClearTypeGridFit); + + UiFillRect(g, gd::RectF(0.0f, 0.0f, static_cast(width), static_cast(height)), + m_theme.windowBg); + + m_toolbar.Paint(g); + m_panel.Paint(g); + m_plot.Paint(g); + + UiFillRect(g, m_splitterRect, m_theme.border); + + PaintStatusBar(g); + } + + HDC windowDc = GetDC(m_hwnd); + if (windowDc != nullptr) + { + BitBlt(windowDc, 0, 0, width, height, m_backDc, 0, 0, SRCCOPY); + ReleaseDC(m_hwnd, windowDc); + } + } + + void Application::PaintStatusBar(gd::Graphics& g) + { + UiFillRect(g, m_statusRect, m_theme.statusBg); + UiDrawLine(g, m_statusRect.X, m_statusRect.Y, m_statusRect.X + m_statusRect.Width, m_statusRect.Y, + m_theme.border, 1.0f); + + const float padding = ScaleOf(12.0f, m_scale); + const float halfWidth = m_statusRect.Width * 0.5f; + + std::wstring leftText; + if (m_document.IsLoaded()) + { + leftText = m_document.FileName() + L" · " + + FormatCount(static_cast(m_document.SampleCount())) + L" 采样点 · " + + std::to_wstring(m_document.Signals().size()) + L" 信号 · " + + std::to_wstring(m_document.Lanes().size()) + L" 网格"; + } + else + { + leftText = L"未加载数据 · 请点击「打开 CSV」或拖拽文件到窗口"; + } + + UiText(g, leftText, m_fonts.uiSmall, + gd::RectF(m_statusRect.X + padding, m_statusRect.Y, halfWidth - padding, m_statusRect.Height), + m_theme.text, TextAlign::LEFT); + + std::wstring rightText = kStatusHint; + std::uint32_t rightColor = m_theme.textMuted; + if (!m_statusMessage.empty()) + { + rightText = m_statusMessage; + rightColor = m_statusIsError ? m_theme.danger : m_theme.textStrong; + } + else if (m_plot.HasCursor() && m_document.IsLoaded()) + { + rightText = L"光标 · " + FormatValue(m_plot.CursorTime()); + rightColor = m_theme.text; + } + + UiText(g, rightText, m_fonts.uiSmall, + gd::RectF(m_statusRect.X + halfWidth, m_statusRect.Y, halfWidth - padding, m_statusRect.Height), + rightColor, TextAlign::RIGHT); + } + + void Application::SetStatusMessage(const std::wstring& text, bool isError) + { + m_statusMessage = text; + m_statusIsError = isError; + } + + void Application::UpdateWindowTitle() + { + std::wstring title = kWindowTitle; + if (m_document.IsLoaded()) + { + title += L" — "; + title += m_document.FileName(); + } + SetWindowTextW(m_hwnd, title.c_str()); + } + + void Application::OpenFileDialog() + { + wchar_t buffer[MAX_PATH * 4] = {}; + + OPENFILENAMEW dialog = {}; + dialog.lStructSize = sizeof(dialog); + dialog.hwndOwner = m_hwnd; + dialog.lpstrFilter = L"CSV 数据文件 (*.csv)\0*.csv\0文本数据文件 (*.txt)\0*.txt\0所有文件 (*.*)\0*.*\0\0"; + dialog.lpstrFile = buffer; + dialog.nMaxFile = MAX_PATH * 4; + dialog.lpstrTitle = L"打开仿真数据文件"; + dialog.lpstrDefExt = L"csv"; + dialog.Flags = OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_EXPLORER | OFN_NOCHANGEDIR; + + if (GetOpenFileNameW(&dialog) == FALSE) + { + return; + } + LoadFile(buffer); + } + + bool Application::LoadFile(const std::wstring& path) + { + CsvTable table; + std::wstring error; + if (!LoadCsvTable(path, table, error)) + { + SetStatusMessage(L"解析失败:" + error, true); + InvalidateRect(m_hwnd, nullptr, FALSE); + return false; + } + + SignalSet loaded; + if (!loaded.BuildFromTable(table, path, error)) + { + SetStatusMessage(L"解析失败:" + error, true); + InvalidateRect(m_hwnd, nullptr, FALSE); + return false; + } + + m_document = std::move(loaded); + m_panel.SetDocument(&m_document); + m_plot.SetDocument(&m_document); + m_plot.FitAll(); + Relayout(); + + std::wstring message = L"已加载 " + m_document.FileName() + L"(" + + FormatCount(static_cast(m_document.SampleCount())) + + L" 行采样,时间列:" + m_document.TimeColumnName() + L")"; + if (!m_document.TimeMonotonic()) + { + message += L" 提示:时间列非单调递增,光标取值使用最近邻策略。"; + } + SetStatusMessage(message, false); + + UpdateWindowTitle(); + InvalidateRect(m_hwnd, nullptr, TRUE); + return true; + } + + void Application::LoadCommandLineFile() + { + int argumentCount = 0; + LPWSTR* arguments = CommandLineToArgvW(GetCommandLineW(), &argumentCount); + if (arguments == nullptr) + { + return; + } + + for (int index = 1; index < argumentCount; ++index) + { + const std::wstring argument = arguments[index]; + if (argument.empty() || argument[0] == L'-' || argument[0] == L'/') + { + continue; + } + LoadFile(argument); + break; + } + LocalFree(arguments); + } + + void Application::ExecuteToolbarAction(ToolbarAction action) + { + switch (action) + { + case ToolbarAction::OPEN_FILE: + OpenFileDialog(); + break; + case ToolbarAction::FIT_ALL: + m_plot.FitAll(); + break; + case ToolbarAction::FIT_TIME: + m_plot.FitTime(); + break; + case ToolbarAction::ZOOM_IN: + m_plot.ZoomTimeStep(1.4); + break; + case ToolbarAction::ZOOM_OUT: + m_plot.ZoomTimeStep(1.0 / 1.4); + break; + case ToolbarAction::MERGE_LANES: + if (m_document.IsLoaded()) + { + m_document.MergeIntoSingleLane(); + m_panel.SetDocument(&m_document); + m_plot.FitAll(); + Relayout(); + SetStatusMessage(L"已将全部信号合并到同一网格。", false); + } + break; + case ToolbarAction::SPLIT_LANES: + if (m_document.IsLoaded()) + { + m_document.SplitIntoLanes(); + m_panel.SetDocument(&m_document); + m_plot.FitAll(); + Relayout(); + SetStatusMessage(L"已按信号拆分为独立网格。", false); + } + break; + case ToolbarAction::RESET_VIEW: + m_plot.FitAll(); + break; + case ToolbarAction::NONE: + default: + break; + } + InvalidateRect(m_hwnd, nullptr, TRUE); + } + + bool Application::HandleKeyDown(WPARAM key) + { + const bool control = (GetKeyState(VK_CONTROL) & 0x8000) != 0; + + if (control && key == L'O') + { + OpenFileDialog(); + return true; + } + + switch (key) + { + case VK_ESCAPE: + m_plot.ClearCursor(); + return true; + case VK_ADD: + case VK_OEM_PLUS: + m_plot.ZoomTimeStep(1.4); + return true; + case VK_SUBTRACT: + case VK_OEM_MINUS: + m_plot.ZoomTimeStep(1.0 / 1.4); + return true; + case VK_LEFT: + m_plot.PanTimeByFraction(-0.12); + return true; + case VK_RIGHT: + m_plot.PanTimeByFraction(0.12); + return true; + case VK_UP: + m_plot.ScrollLanesByFraction(-0.12); + return true; + case VK_DOWN: + m_plot.ScrollLanesByFraction(0.12); + return true; + default: + break; + } + + if (key == L'F') + { + m_plot.FitAll(); + return true; + } + if (key == L'G') + { + m_plot.FitTime(); + return true; + } + return false; + } + + bool Application::HandleMouseMove(int x, int y, bool buttonDown) + { + if (buttonDown && m_activeTarget != Target::NONE) + { + switch (m_activeTarget) + { + case Target::SPLITTER: + if (m_draggingSplitter && m_scale > 0.0f) + { + const float desired = static_cast(x - m_splitterGrabOffset) / m_scale; + const float maximum = std::min(kMaxPanelWidth, + static_cast(kMinClientWidth) / m_scale); + m_panelWidth = std::max(kMinPanelWidth, std::min(desired, maximum)); + Relayout(); + return true; + } + return false; + case Target::PLOT: + return m_plot.OnMouseMove(x, y); + case Target::PANEL: + return m_panel.OnMouseMove(x, y); + case Target::TOOLBAR: + return m_toolbar.OnMouseMove(x, y); + case Target::NONE: + default: + return false; + } + } + + bool changed = false; + changed = (m_toolbar.HitTest(x, y) ? m_toolbar.OnMouseMove(x, y) : m_toolbar.OnMouseLeave()) || changed; + changed = (m_panel.HitTest(x, y) ? m_panel.OnMouseMove(x, y) : m_panel.OnMouseLeave()) || changed; + changed = (m_plot.HitTest(x, y) ? m_plot.OnMouseMove(x, y) : m_plot.OnMouseLeave()) || changed; + return changed; + } + + LRESULT Application::HandleMessage(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) + { + switch (message) + { + case WM_SIZE: + { + ReleaseBackBuffer(); + Relayout(); + InvalidateRect(hwnd, nullptr, FALSE); + return 0; + } + case WM_GETMINMAXINFO: + { + MINMAXINFO* info = reinterpret_cast(lParam); + info->ptMinTrackSize.x = static_cast(ScaleOf(static_cast(kMinClientWidth), m_scale)); + info->ptMinTrackSize.y = static_cast(ScaleOf(static_cast(kMinClientHeight), m_scale)); + return 0; + } + case WM_DPICHANGED: + { + const UINT dpi = HIWORD(wParam); + const RECT* suggested = reinterpret_cast(lParam); + if (suggested != nullptr) + { + SetWindowPos(hwnd, nullptr, suggested->left, suggested->top, + suggested->right - suggested->left, suggested->bottom - suggested->top, + SWP_NOZORDER | SWP_NOACTIVATE); + } + ApplyDpi(dpi); + InvalidateRect(hwnd, nullptr, FALSE); + return 0; + } + case WM_ERASEBKGND: + return 1; + case WM_PAINT: + { + PAINTSTRUCT paintStruct = {}; + BeginPaint(hwnd, &paintStruct); + Render(); + EndPaint(hwnd, &paintStruct); + return 0; + } + case WM_MOUSEMOVE: + { + if (!m_trackingLeave) + { + TRACKMOUSEEVENT track = {}; + track.cbSize = sizeof(track); + track.dwFlags = TME_LEAVE; + track.hwndTrack = hwnd; + TrackMouseEvent(&track); + m_trackingLeave = true; + } + + const int x = GET_X_LPARAM(lParam); + const int y = GET_Y_LPARAM(lParam); + const bool buttonDown = (wParam & (MK_LBUTTON | MK_RBUTTON)) != 0; + if (HandleMouseMove(x, y, buttonDown)) + { + InvalidateRect(hwnd, nullptr, FALSE); + } + return 0; + } + case WM_MOUSELEAVE: + { + m_trackingLeave = false; + bool changed = m_toolbar.OnMouseLeave(); + changed = m_panel.OnMouseLeave() || changed; + changed = m_plot.OnMouseLeave() || changed; + if (changed) + { + InvalidateRect(hwnd, nullptr, FALSE); + } + return 0; + } + case WM_LBUTTONDOWN: + { + SetFocus(hwnd); + const int x = GET_X_LPARAM(lParam); + const int y = GET_Y_LPARAM(lParam); + + if (UiPointInRect(m_splitterRect, static_cast(x), static_cast(y))) + { + SetCapture(hwnd); + m_draggingSplitter = true; + m_activeTarget = Target::SPLITTER; + m_splitterGrabOffset = x - static_cast(m_splitterRect.X); + return 0; + } + + if (m_toolbar.HitTest(x, y)) + { + SetCapture(hwnd); + m_activeTarget = Target::TOOLBAR; + ToolbarAction action = ToolbarAction::NONE; + m_toolbar.OnLButtonDown(x, y, action); + InvalidateRect(hwnd, nullptr, FALSE); + return 0; + } + + if (m_panel.HitTest(x, y)) + { + SetCapture(hwnd); + m_activeTarget = Target::PANEL; + if (m_panel.OnLButtonDown(x, y)) + { + InvalidateRect(hwnd, nullptr, FALSE); + } + return 0; + } + + if (m_plot.HitTest(x, y)) + { + SetCapture(hwnd); + m_activeTarget = Target::PLOT; + if (m_plot.OnLButtonDown(x, y)) + { + InvalidateRect(hwnd, nullptr, FALSE); + } + return 0; + } + + m_activeTarget = Target::NONE; + return 0; + } + case WM_LBUTTONUP: + { + const int x = GET_X_LPARAM(lParam); + const int y = GET_Y_LPARAM(lParam); + ReleaseCapture(); + + bool changed = false; + switch (m_activeTarget) + { + case Target::SPLITTER: + m_draggingSplitter = false; + break; + case Target::TOOLBAR: + { + ToolbarAction action = ToolbarAction::NONE; + if (m_toolbar.OnLButtonUp(x, y, action)) + { + changed = true; + } + m_activeTarget = Target::NONE; + if (action != ToolbarAction::NONE) + { + ExecuteToolbarAction(action); + return 0; + } + break; + } + case Target::PANEL: + changed = m_panel.OnLButtonUp(x, y) || changed; + break; + case Target::PLOT: + changed = m_plot.OnLButtonUp(x, y) || changed; + break; + case Target::NONE: + default: + break; + } + + m_activeTarget = Target::NONE; + changed = true; + if (changed) + { + InvalidateRect(hwnd, nullptr, FALSE); + } + return 0; + } + case WM_LBUTTONDBLCLK: + { + const int x = GET_X_LPARAM(lParam); + const int y = GET_Y_LPARAM(lParam); + bool changed = false; + if (m_panel.HitTest(x, y)) + { + changed = m_panel.OnDoubleClick(x, y); + } + else if (m_plot.HitTest(x, y)) + { + changed = m_plot.OnDoubleClick(x, y); + } + if (changed) + { + InvalidateRect(hwnd, nullptr, FALSE); + } + return 0; + } + case WM_RBUTTONDOWN: + { + const int x = GET_X_LPARAM(lParam); + const int y = GET_Y_LPARAM(lParam); + + if (m_panel.HitTest(x, y)) + { + SetCapture(hwnd); + m_activeTarget = Target::PANEL; + if (m_panel.OnRButtonDown(x, y)) + { + InvalidateRect(hwnd, nullptr, FALSE); + } + ReleaseCapture(); + m_activeTarget = Target::NONE; + return 0; + } + if (m_plot.HitTest(x, y)) + { + SetCapture(hwnd); + m_activeTarget = Target::PLOT; + if (m_plot.OnRButtonDown(x, y)) + { + InvalidateRect(hwnd, nullptr, FALSE); + } + return 0; + } + return 0; + } + case WM_RBUTTONUP: + { + const int x = GET_X_LPARAM(lParam); + const int y = GET_Y_LPARAM(lParam); + bool changed = false; + if (m_activeTarget == Target::PLOT) + { + changed = m_plot.OnRButtonUp(x, y); + } + ReleaseCapture(); + m_activeTarget = Target::NONE; + if (changed) + { + InvalidateRect(hwnd, nullptr, FALSE); + } + return 0; + } + case WM_MOUSEWHEEL: + { + POINT point = {GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)}; + ScreenToClient(hwnd, &point); + + const int delta = GET_WHEEL_DELTA_WPARAM(wParam); + const bool control = (GET_KEYSTATE_WPARAM(wParam) & MK_CONTROL) != 0; + const bool shift = (GET_KEYSTATE_WPARAM(wParam) & MK_SHIFT) != 0; + const bool alt = (GetKeyState(VK_MENU) & 0x8000) != 0; + + bool changed = false; + if (m_panel.HitTest(point.x, point.y)) + { + changed = m_panel.OnMouseWheel(delta); + } + else if (m_plot.HitTest(point.x, point.y)) + { + changed = m_plot.OnMouseWheel(point.x, point.y, delta, control, shift, alt); + } + if (changed) + { + InvalidateRect(hwnd, nullptr, FALSE); + } + return 0; + } + case WM_SETCURSOR: + { + if (LOWORD(lParam) == HTCLIENT) + { + POINT point = {}; + GetCursorPos(&point); + ScreenToClient(hwnd, &point); + + HCURSOR cursor = nullptr; + if (UiPointInRect(m_splitterRect, static_cast(point.x), static_cast(point.y))) + { + cursor = LoadCursorW(nullptr, IDC_SIZEWE); + } + else if (m_plot.HitTest(point.x, point.y)) + { + cursor = m_plot.QueryCursor(point.x, point.y); + } + if (cursor == nullptr) + { + cursor = LoadCursorW(nullptr, IDC_ARROW); + } + SetCursor(cursor); + return TRUE; + } + break; + } + case WM_KEYDOWN: + if (HandleKeyDown(wParam)) + { + InvalidateRect(hwnd, nullptr, FALSE); + return 0; + } + break; + case WM_DROPFILES: + { + HDROP drop = reinterpret_cast(wParam); + const UINT length = DragQueryFileW(drop, 0, nullptr, 0); + if (length > 0) + { + std::vector buffer(static_cast(length) + 1, L'\0'); + if (DragQueryFileW(drop, 0, buffer.data(), length + 1) > 0) + { + LoadFile(std::wstring(buffer.data())); + } + } + DragFinish(drop); + return 0; + } + case WM_DESTROY: + PostQuitMessage(0); + return 0; + default: + break; + } + + return DefWindowProcW(hwnd, message, wParam, lParam); + } + +} // namespace jdi diff --git a/Components/Main/Inc/Main.hpp b/Components/Main/Inc/Main.hpp new file mode 100644 index 0000000..1974559 --- /dev/null +++ b/Components/Main/Inc/Main.hpp @@ -0,0 +1,21 @@ +#ifndef __MAIN_HPP__ +#define __MAIN_HPP__ + +#include + +namespace jdi +{ + + /** + * @brief 应用程序入口逻辑(GUI 子系统)。 + * @param [IN] instance 当前模块实例句柄。 + * @param [IN] prevInstance 保留参数,Win32 下恒为空。 + * @param [IN] commandLine 命令行参数字符串(ANSI 编码)。 + * @param [IN] showCommand 主窗口显示方式。 + * @return 进程退出码。 + */ + int RunApplication(HINSTANCE instance, HINSTANCE prevInstance, LPSTR commandLine, int showCommand); + +} // namespace jdi + +#endif // __MAIN_HPP__ diff --git a/Components/Main/Src/Main.cpp b/Components/Main/Src/Main.cpp new file mode 100644 index 0000000..46ed73b --- /dev/null +++ b/Components/Main/Src/Main.cpp @@ -0,0 +1,26 @@ +#include "Main.hpp" + +#include "Application.hpp" + +namespace jdi +{ + + int RunApplication(HINSTANCE instance, HINSTANCE /*prevInstance*/, LPSTR /*commandLine*/, int showCommand) + { + return Application::Instance().Run(instance, showCommand); + } + +} // namespace jdi + +/** + * @brief Windows GUI 程序入口。 + * @param [IN] instance 当前模块实例句柄。 + * @param [IN] prevInstance 保留参数,Win32 下恒为空。 + * @param [IN] commandLine 命令行参数字符串(ANSI 编码)。 + * @param [IN] showCommand 主窗口显示方式。 + * @return 进程退出码。 + */ +int WINAPI WinMain(HINSTANCE instance, HINSTANCE prevInstance, LPSTR commandLine, int showCommand) +{ + return jdi::RunApplication(instance, prevInstance, commandLine, showCommand); +} diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp new file mode 100644 index 0000000..aa275ce --- /dev/null +++ b/Components/PlotView/Inc/PlotView.hpp @@ -0,0 +1,396 @@ +#ifndef __PLOT_VIEW_HPP__ +#define __PLOT_VIEW_HPP__ + +#include +#include +#include + +#include +#include + +#include "PaintUtil.hpp" +#include "SignalSet.hpp" +#include "Theme.hpp" + +namespace jdi +{ + + /** + * @brief 绘图区拖拽交互模式。 + */ + enum class PlotDragMode : int + { + NONE = 0, /**< 未拖拽。 */ + PAN_TIME, /**< 水平平移时间轴。 */ + ZOOM_Y, /**< 纵向缩放某网格的幅值轴。 */ + BOX_ZOOM, /**< 右键框选缩放。 */ + SCROLL_LANES /**< 拖动网格滚动条。 */ + }; + + /** + * @brief 波形绘图区:网格、时间轴、光标、缩放与自适应。 + */ + class PlotView + { + public: + PlotView(); + + /** + * @brief 绑定字体与主题资源。 + * @param [IN] fonts 字体集合,需在视图生命周期内保持有效。 + * @param [IN] theme 主题配色,需在视图生命周期内保持有效。 + */ + void Setup(const UiFonts* fonts, const Theme* theme); + + /** + * @brief 绑定数据集。 + * @param [IN] document 数据集指针,允许为空。 + */ + void SetDocument(SignalSet* document); + + /** + * @brief 设置 DPI 缩放比例。 + * @param [IN] scale 缩放比例(1.0 对应 96 DPI)。 + */ + void SetScale(float scale); + + /** + * @brief 按给定区域重新排布绘图区。 + * @param [IN] area 可用区域。 + */ + void Layout(const gd::RectF& area); + + /** + * @brief 绘制绘图区。 + * @param [IN,OUT] g 绘图上下文。 + */ + void Paint(gd::Graphics& g); + + /** + * @brief 处理鼠标移动(含拖拽)。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @return 需要重绘返回 true。 + */ + bool OnMouseMove(int x, int y); + + /** + * @brief 处理鼠标左键按下。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @return 需要重绘返回 true。 + */ + bool OnLButtonDown(int x, int y); + + /** + * @brief 处理鼠标左键抬起。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @return 需要重绘返回 true。 + */ + bool OnLButtonUp(int x, int y); + + /** + * @brief 处理鼠标右键按下(开始框选缩放)。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @return 需要重绘返回 true。 + */ + bool OnRButtonDown(int x, int y); + + /** + * @brief 处理鼠标右键抬起(结束框选缩放或清除光标)。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @return 需要重绘返回 true。 + */ + bool OnRButtonUp(int x, int y); + + /** + * @brief 处理鼠标滚轮。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @param [IN] delta 滚轮增量。 + * @param [IN] ctrl 是否按下 Ctrl(缩放当前网格幅值)。 + * @param [IN] shift 是否按下 Shift(平移时间轴)。 + * @param [IN] alt 是否按下 Alt(纵向滚动网格)。 + * @return 需要重绘返回 true。 + */ + bool OnMouseWheel(int x, int y, int delta, bool ctrl, bool shift, bool alt); + + /** + * @brief 处理鼠标双击(恢复自适应视图)。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @return 需要重绘返回 true。 + */ + bool OnDoubleClick(int x, int y); + + /** + * @brief 处理鼠标离开绘图区。 + * @return 需要重绘返回 true。 + */ + bool OnMouseLeave(); + + /** + * @brief 判断坐标是否落在绘图区范围内。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @return 落在范围内返回 true。 + */ + bool HitTest(int x, int y) const; + + /** + * @brief 查询当前鼠标位置应使用的系统光标。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @return 系统光标句柄,未命中特殊区域返回 nullptr。 + */ + HCURSOR QueryCursor(int x, int y) const; + + /** + * @brief 时间轴自适应到全部数据范围。 + */ + void FitTime(); + + /** + * @brief 时间轴与幅值轴同时自适应。 + */ + void FitAll(); + + /** + * @brief 以给定倍率缩放时间轴(以视图中心为锚点)。 + * @param [IN] factor 缩放倍率,大于 1 表示放大。 + */ + void ZoomTimeStep(double factor); + + /** + * @brief 按当前视图跨度的比例平移时间轴。 + * @param [IN] fraction 平移比例,正值向右(时间增大)平移。 + */ + void PanTimeByFraction(double fraction); + + /** + * @brief 按网格内容高度的比例纵向滚动网格。 + * @param [IN] fraction 滚动比例,正值向下滚动。 + */ + void ScrollLanesByFraction(double fraction); + + /** + * @brief 获取网格当前内容总高度。 + * @return 内容高度(像素)。 + */ + float LaneContentHeight() const; + + /** + * @brief 清除已固定的时间光标。 + */ + void ClearCursor(); + + /** + * @brief 判断当前是否存在激活的时间光标。 + * @return 存在返回 true。 + */ + bool HasCursor() const { return m_cursorVisible; } + + /** + * @brief 获取当前激活光标对应的时间。 + * @return 光标时间。 + */ + double CursorTime() const { return m_cursorTime; } + + /** + * @brief 获取当前视图左边界时间。 + * @return 视图左边界时间。 + */ + double ViewMinTime() const { return m_viewMin; } + + /** + * @brief 获取当前视图右边界时间。 + * @return 视图右边界时间。 + */ + double ViewMaxTime() const { return m_viewMax; } + + /** + * @brief 获取绘图区(波形区域)矩形。 + * @return 波形区域矩形。 + */ + const gd::RectF& WaveformRect() const { return m_plotRect; } + + private: + /** + * @brief 单个网格的绘制信息。 + */ + struct LaneVisual + { + gd::RectF rect; /**< 网格波形区域。 */ + double yMin = 0.0; /**< 幅值下限。 */ + double yMax = 1.0; /**< 幅值上限。 */ + bool empty = true; /**< 是否没有可见信号。 */ + }; + + /** + * @brief 重新计算各网格的布局与幅值范围。 + */ + void BuildLaneVisuals(); + + /** + * @brief 限制网格滚动偏移量。 + */ + void ClampLaneScroll(); + + /** + * @brief 将时间映射为绘图区横坐标。 + * @param [IN] time 时间值。 + * @return 横坐标。 + */ + float TimeToX(double time) const; + + /** + * @brief 将绘图区横坐标还原为时间。 + * @param [IN] x 横坐标。 + * @return 时间值。 + */ + double XToTime(float x) const; + + /** + * @brief 将幅值映射为纵坐标。 + * @param [IN] value 幅值。 + * @param [IN] lane 网格绘制信息。 + * @return 纵坐标。 + */ + float ValueToY(double value, const LaneVisual& lane) const; + + /** + * @brief 将纵坐标还原为幅值。 + * @param [IN] y 纵坐标。 + * @param [IN] lane 网格绘制信息。 + * @return 幅值。 + */ + double YToValue(float y, const LaneVisual& lane) const; + + /** + * @brief 查询指定纵坐标落在哪个网格内。 + * @param [IN] y 纵坐标。 + * @return 网格下标,未命中返回 -1。 + */ + int LaneAtY(int y) const; + + /** + * @brief 限制时间视图范围,避免过度平移到数据之外。 + */ + void ClampTimeView(); + + /** + * @brief 以指定锚点缩放时间轴。 + * @param [IN] factor 缩放倍率,大于 1 表示放大。 + * @param [IN] anchorTime 锚点时间。 + */ + void ZoomTimeAt(double factor, double anchorTime); + + /** + * @brief 构建单条信号在当前视图下的折线点序列。 + * @param [IN] signal 目标信号。 + * @param [IN] lane 所属网格绘制信息。 + * @param [IN] stepMode 是否使用阶梯保持连线。 + * @param [OUT] points 输出点序列。 + * @param [OUT] breaks 输出每段折线的起始下标(用于处理缺失值断线)。 + */ + void BuildPolyline(const Signal& signal, const LaneVisual& lane, bool stepMode, + std::vector& points, std::vector& breaks) const; + + /** + * @brief 绘制单个网格。 + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] laneIndex 网格下标。 + * @param [IN] lane 网格绘制信息。 + * @param [IN] timeTicks 时间刻度序列。 + * @param [IN] timeStep 时间刻度步长。 + */ + void PaintLane(gd::Graphics& g, int laneIndex, const LaneVisual& lane, const std::vector& timeTicks, + double timeStep); + + /** + * @brief 绘制底部时间轴。 + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] timeTicks 时间刻度序列。 + * @param [IN] timeStep 时间刻度步长。 + */ + void PaintTimeAxis(gd::Graphics& g, const std::vector& timeTicks, double timeStep); + + /** + * @brief 绘制时间光标及其读数。 + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] lanes 网格绘制信息集合。 + */ + void PaintCursor(gd::Graphics& g, const std::vector& lanes); + + /** + * @brief 绘制网格纵向滚动条。 + * @param [IN,OUT] g 绘图上下文。 + */ + void PaintScrollBar(gd::Graphics& g); + + /** + * @brief 绘制网格标题(信号图例)。 + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] laneIndex 网格下标。 + * @param [IN] lane 网格绘制信息。 + */ + void PaintLaneLegend(gd::Graphics& g, int laneIndex, const LaneVisual& lane); + + /** + * @brief 在网格内绘制各信号的幅值读数。 + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] laneIndex 网格下标。 + * @param [IN] lane 网格绘制信息。 + */ + void PaintLaneReadout(gd::Graphics& g, int laneIndex, const LaneVisual& lane); + + const UiFonts* m_fonts = nullptr; /**< 字体资源。 */ + const Theme* m_theme = nullptr; /**< 主题资源。 */ + SignalSet* m_document = nullptr; /**< 数据集。 */ + float m_scale = 1.0f; /**< DPI 缩放比例。 */ + + gd::RectF m_area; /**< 绘图区整体区域。 */ + gd::RectF m_plotRect; /**< 波形绘制区域。 */ + gd::RectF m_timeAxisRect; /**< 时间轴区域。 */ + gd::RectF m_scrollBarRect; /**< 网格滚动条区域。 */ + + float m_yAxisWidth = 64.0f; /**< 纵轴标签列宽度(逻辑像素)。 */ + float m_timeAxisHeight = 34.0f; /**< 时间轴高度(逻辑像素)。 */ + float m_laneGap = 6.0f; /**< 网格间距(逻辑像素)。 */ + float m_minLaneHeight = 46.0f; /**< 网格最小高度(逻辑像素)。 */ + float m_laneScroll = 0.0f; /**< 网格纵向滚动偏移量。 */ + float m_laneContentHeight = 0.0f; /**< 网格内容总高度。 */ + bool m_scrollBarVisible = false; /**< 是否需要显示滚动条。 */ + + double m_viewMin = 0.0; /**< 视图左边界时间。 */ + double m_viewMax = 1.0; /**< 视图右边界时间。 */ + + bool m_cursorVisible = false; /**< 是否存在激活光标。 */ + bool m_cursorPinned = false; /**< 光标是否已被固定。 */ + double m_cursorTime = 0.0; /**< 光标时间。 */ + + PlotDragMode m_dragMode = PlotDragMode::NONE; /**< 当前拖拽模式。 */ + int m_dragLane = -1; /**< 拖拽所作用的网格下标。 */ + bool m_dragMoved = false; /**< 拖拽过程中是否发生了位移。 */ + int m_dragStartX = 0; /**< 拖拽起点横坐标。 */ + int m_dragStartY = 0; /**< 拖拽起点纵坐标。 */ + int m_dragLastX = 0; /**< 上一次拖拽横坐标。 */ + int m_dragLastY = 0; /**< 上一次拖拽纵坐标。 */ + double m_dragStartViewMin = 0.0; /**< 拖拽起始视图左边界。 */ + double m_dragStartViewMax = 1.0; /**< 拖拽起始视图右边界。 */ + double m_dragAnchorValue = 0.0; /**< 纵轴缩放锚点幅值。 */ + double m_dragAnchorRatio = 0.5; /**< 纵轴缩放锚点相对位置。 */ + double m_dragStartYMin = 0.0; /**< 拖拽起始幅值下限。 */ + double m_dragStartYMax = 1.0; /**< 拖拽起始幅值上限。 */ + gd::RectF m_boxRect; /**< 框选缩放矩形。 */ + + int m_hoverLane = -1; /**< 当前悬停网格下标。 */ + std::vector m_lanes; /**< 当前网格绘制信息集合。 */ + }; + +} // namespace jdi + +#endif // __PLOT_VIEW_HPP__ diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp new file mode 100644 index 0000000..3321c80 --- /dev/null +++ b/Components/PlotView/Src/PlotView.cpp @@ -0,0 +1,1351 @@ +#include "PlotView.hpp" + +#include +#include +#include + +#include "Utf8Util.hpp" + +namespace jdi +{ + + namespace + { + const int kTimeTickTarget = 8; /**< 时间轴期望刻度数。 */ + const int kYTickTarget = 4; /**< 幅值轴期望刻度数。 */ + const float kScrollBarThickness = 9.0f; /**< 滚动条宽度(逻辑像素)。 */ + const float kLanePadding = 8.0f; /**< 网格内边距。 */ + const float kAxisLabelGap = 10.0f; /**< 轴上文字与轴线的间距。 */ + const size_t kMaxPolylinePoints = 20000;/**< 单条折线最大顶点数。 */ + const size_t kMaxScatterPoints = 2500; /**< 单条散点最大绘制点数。 */ + const float kMinScatterRadius = 1.6f; /**< 散点最小半径(逻辑像素)。 */ + const float kMaxScatterRadius = 4.0f; /**< 散点最大半径(逻辑像素)。 */ + const double kMinTimeSpan = 1.0e-12; /**< 允许的最小时间跨度。 */ + + /** + * @brief 将数值限制到合法区间。 + * @param [IN] value 输入值。 + * @param [IN] low 下限。 + * @param [IN] high 上限。 + * @return 限制后的数值。 + */ + double ClampDouble(double value, double low, double high) + { + if (value < low) + { + return low; + } + if (value > high) + { + return high; + } + return value; + } + } + + PlotView::PlotView() = default; + + void PlotView::Setup(const UiFonts* fonts, const Theme* theme) + { + m_fonts = fonts; + m_theme = theme; + } + + void PlotView::SetDocument(SignalSet* document) + { + m_document = document; + m_laneScroll = 0.0f; + m_cursorVisible = false; + m_cursorPinned = false; + m_hoverLane = -1; + if (m_document != nullptr && m_document->IsLoaded()) + { + FitTime(); + } + } + + void PlotView::SetScale(float scale) + { + m_scale = (scale > 0.1f) ? scale : 1.0f; + } + + void PlotView::Layout(const gd::RectF& area) + { + m_area = area; + + m_yAxisWidth = 74.0f * m_scale; + m_timeAxisHeight = 32.0f * m_scale; + m_laneGap = 6.0f * m_scale; + m_minLaneHeight = 46.0f * m_scale; + + const float topPadding = 6.0f * m_scale; + const float availHeight = std::max(0.0f, area.Height - m_timeAxisHeight - topPadding); + + // 预估是否需要纵向滚动条:仅取决于网格数量与最小网格高度。 + std::size_t laneCount = 0; + if (m_document != nullptr && m_document->IsLoaded()) + { + laneCount = m_document->Lanes().size(); + } + if (laneCount > 0) + { + const float needed = static_cast(laneCount) * m_minLaneHeight + + m_laneGap * static_cast(laneCount - 1); + m_scrollBarVisible = (needed > availHeight); + } + else + { + m_scrollBarVisible = false; + } + + const float rightReserve = m_scrollBarVisible ? (kScrollBarThickness * m_scale + 6.0f * m_scale) + : (6.0f * m_scale); + const float plotWidth = std::max(0.0f, area.Width - m_yAxisWidth - rightReserve); + + m_plotRect = gd::RectF(area.X + m_yAxisWidth, area.Y + topPadding, plotWidth, availHeight); + m_timeAxisRect = gd::RectF(m_plotRect.X, m_plotRect.Y + m_plotRect.Height, m_plotRect.Width, + m_timeAxisHeight); + m_scrollBarRect = gd::RectF(area.X + area.Width - kScrollBarThickness * m_scale - 3.0f * m_scale, + m_plotRect.Y, kScrollBarThickness * m_scale, m_plotRect.Height); + + BuildLaneVisuals(); + } + + float PlotView::LaneContentHeight() const + { + return m_laneContentHeight; + } + + void PlotView::ClampLaneScroll() + { + const float maxOffset = std::max(0.0f, m_laneContentHeight - m_plotRect.Height); + m_laneScroll = ClampDouble(m_laneScroll, 0.0, maxOffset); + } + + void PlotView::BuildLaneVisuals() + { + m_lanes.clear(); + if (m_document == nullptr || !m_document->IsLoaded()) + { + m_laneContentHeight = 0.0f; + return; + } + + const std::vector& lanes = m_document->Lanes(); + const std::size_t count = lanes.size(); + if (count == 0) + { + m_laneContentHeight = 0.0f; + return; + } + + const float gap = m_laneGap * m_scale; + float laneHeight = (m_plotRect.Height - gap * static_cast(count - 1)) / + static_cast(count); + if (laneHeight < m_minLaneHeight * m_scale) + { + laneHeight = m_minLaneHeight * m_scale; + } + m_laneContentHeight = laneHeight * static_cast(count) + gap * static_cast(count - 1); + ClampLaneScroll(); + + m_lanes.resize(count); + for (std::size_t index = 0; index < count; ++index) + { + LaneVisual& visual = m_lanes[index]; + visual.rect = gd::RectF(m_plotRect.X, + m_plotRect.Y + static_cast(index) * (laneHeight + gap) - m_laneScroll, + m_plotRect.Width, laneHeight); + m_document->ComputeLaneRange(static_cast(index), visual.yMin, visual.yMax); + if (!(visual.yMax > visual.yMin)) + { + visual.yMin = 0.0; + visual.yMax = 1.0; + } + + visual.empty = true; + for (const int signalIndex : lanes[index].signalIndices) + { + if (signalIndex >= 0 && signalIndex < static_cast(m_document->Signals().size()) && + m_document->Signals()[static_cast(signalIndex)].visible) + { + visual.empty = false; + break; + } + } + } + } + + float PlotView::TimeToX(double time) const + { + const double span = m_viewMax - m_viewMin; + if (!(span > 0.0) || m_plotRect.Width <= 0.0f) + { + return m_plotRect.X; + } + return m_plotRect.X + static_cast((time - m_viewMin) / span * m_plotRect.Width); + } + + double PlotView::XToTime(float x) const + { + if (m_plotRect.Width <= 0.0f) + { + return m_viewMin; + } + const double ratio = static_cast(x - m_plotRect.X) / static_cast(m_plotRect.Width); + return m_viewMin + ratio * (m_viewMax - m_viewMin); + } + + float PlotView::ValueToY(double value, const LaneVisual& lane) const + { + const double span = lane.yMax - lane.yMin; + if (!(span > 0.0)) + { + return lane.rect.Y + lane.rect.Height * 0.5f; + } + + double ratio = (value - lane.yMin) / span; + ratio = ClampDouble(ratio, -50.0, 50.0); + return lane.rect.Y + lane.rect.Height * static_cast(1.0 - ratio); + } + + double PlotView::YToValue(float y, const LaneVisual& lane) const + { + if (lane.rect.Height <= 0.0f) + { + return lane.yMin; + } + const double ratio = 1.0 - static_cast(y - lane.rect.Y) / static_cast(lane.rect.Height); + return lane.yMin + ratio * (lane.yMax - lane.yMin); + } + + int PlotView::LaneAtY(int y) const + { + const float fy = static_cast(y); + for (std::size_t index = 0; index < m_lanes.size(); ++index) + { + const gd::RectF& rect = m_lanes[index].rect; + if (fy >= rect.Y && fy <= rect.Y + rect.Height) + { + return static_cast(index); + } + } + return -1; + } + + void PlotView::ClampTimeView() + { + if (m_document == nullptr || !m_document->IsLoaded()) + { + return; + } + + double span = m_viewMax - m_viewMin; + if (!(span > 0.0)) + { + span = kMinTimeSpan; + } + + const double dataSpan = std::max(m_document->MaxTime() - m_document->MinTime(), kMinTimeSpan); + const double pad = span * 2.0; + const double minAllowed = m_document->MinTime() - pad; + const double maxAllowed = m_document->MaxTime() + pad; + + if (m_viewMin < minAllowed) + { + m_viewMin = minAllowed; + m_viewMax = m_viewMin + span; + } + if (m_viewMax > maxAllowed) + { + m_viewMax = maxAllowed; + m_viewMin = m_viewMax - span; + } + (void)dataSpan; + } + + void PlotView::ZoomTimeAt(double factor, double anchorTime) + { + if (m_document == nullptr || !m_document->IsLoaded() || !(factor > 0.0)) + { + return; + } + + const double dataSpan = std::max(m_document->MaxTime() - m_document->MinTime(), kMinTimeSpan); + const double minSpan = dataSpan * 1.0e-9; + const double maxSpan = dataSpan * 100.0; + + const double span = m_viewMax - m_viewMin; + double newSpan = span / factor; + newSpan = ClampDouble(newSpan, minSpan, maxSpan); + + const double ratio = (span > 0.0) ? (anchorTime - m_viewMin) / span : 0.5; + m_viewMin = anchorTime - ratio * newSpan; + m_viewMax = m_viewMin + newSpan; + ClampTimeView(); + } + + void PlotView::ZoomTimeStep(double factor) + { + const double anchor = (m_viewMin + m_viewMax) * 0.5; + ZoomTimeAt(factor, anchor); + } + + void PlotView::PanTimeByFraction(double fraction) + { + const double span = m_viewMax - m_viewMin; + const double delta = span * fraction; + m_viewMin += delta; + m_viewMax += delta; + ClampTimeView(); + } + + void PlotView::ScrollLanesByFraction(double fraction) + { + m_laneScroll += static_cast(fraction * static_cast(m_laneContentHeight)); + ClampLaneScroll(); + } + + void PlotView::FitTime() + { + if (m_document == nullptr || !m_document->IsLoaded()) + { + m_viewMin = 0.0; + m_viewMax = 1.0; + return; + } + + m_viewMin = m_document->MinTime(); + m_viewMax = m_document->MaxTime(); + if (!(m_viewMax - m_viewMin > 0.0)) + { + const double center = m_viewMin; + const double padding = std::max(1.0e-6, std::fabs(center) * 0.01); + m_viewMin = center - padding; + m_viewMax = center + padding; + } + } + + void PlotView::FitAll() + { + FitTime(); + m_laneScroll = 0.0f; + if (m_document != nullptr) + { + for (Lane& lane : m_document->Lanes()) + { + lane.autoRange = true; + } + } + } + + void PlotView::ClearCursor() + { + m_cursorVisible = false; + m_cursorPinned = false; + } + + bool PlotView::HitTest(int x, int y) const + { + return UiPointInRect(m_area, static_cast(x), static_cast(y)); + } + + HCURSOR PlotView::QueryCursor(int x, int y) const + { + switch (m_dragMode) + { + case PlotDragMode::PAN_TIME: + case PlotDragMode::BOX_ZOOM: + return LoadCursorW(nullptr, IDC_SIZEALL); + case PlotDragMode::ZOOM_Y: + case PlotDragMode::SCROLL_LANES: + return LoadCursorW(nullptr, IDC_SIZENS); + default: + break; + } + + if (m_scrollBarVisible && UiPointInRect(m_scrollBarRect, static_cast(x), static_cast(y))) + { + return LoadCursorW(nullptr, IDC_SIZENS); + } + if (static_cast(x) < m_plotRect.X && UiPointInRect(m_area, static_cast(x), static_cast(y))) + { + if (LaneAtY(y) >= 0) + { + return LoadCursorW(nullptr, IDC_SIZENS); + } + } + if (UiPointInRect(m_plotRect, static_cast(x), static_cast(y))) + { + return LoadCursorW(nullptr, IDC_CROSS); + } + return nullptr; + } + + bool PlotView::OnMouseMove(int x, int y) + { + if (m_document == nullptr || !m_document->IsLoaded()) + { + return false; + } + + const float fx = static_cast(x); + const float fy = static_cast(y); + + switch (m_dragMode) + { + case PlotDragMode::PAN_TIME: + { + if (m_plotRect.Width <= 0.0f) + { + return false; + } + const double span = m_viewMax - m_viewMin; + const double delta = -static_cast(x - m_dragLastX) / static_cast(m_plotRect.Width) * span; + m_viewMin += delta; + m_viewMax += delta; + ClampTimeView(); + m_dragLastX = x; + m_dragLastY = y; + if (std::abs(x - m_dragStartX) > 3 || std::abs(y - m_dragStartY) > 3) + { + m_dragMoved = true; + } + return true; + } + case PlotDragMode::ZOOM_Y: + { + if (m_dragLane < 0 || m_dragLane >= static_cast(m_lanes.size())) + { + return false; + } + const gd::RectF& rect = m_lanes[static_cast(m_dragLane)].rect; + if (rect.Height <= 0.0f) + { + return false; + } + + // 上拖放大、下拖缩小,并保持按下点处的幅值不动。 + const double travel = static_cast(m_dragStartY - y) / static_cast(rect.Height); + const double factor = std::exp(travel * 2.0); + const double span = m_dragStartYMax - m_dragStartYMin; + if (!(span > 0.0)) + { + return false; + } + + double newSpan = span / factor; + const double dataSpan = std::max(std::fabs(m_dragStartYMax), std::fabs(m_dragStartYMin)); + const double minSpan = std::max(dataSpan * 1.0e-9, 1.0e-12); + newSpan = ClampDouble(newSpan, minSpan, dataSpan * 100.0 + 1.0); + + Lane& lane = m_document->Lanes()[static_cast(m_dragLane)]; + lane.autoRange = false; + lane.yMin = m_dragAnchorValue - m_dragAnchorRatio * newSpan; + lane.yMax = lane.yMin + newSpan; + if (std::abs(y - m_dragStartY) > 2) + { + m_dragMoved = true; + } + return true; + } + case PlotDragMode::BOX_ZOOM: + { + const float left = static_cast(std::min(x, m_dragStartX)); + const float top = static_cast(std::min(y, m_dragStartY)); + m_boxRect = gd::RectF(left, top, static_cast(std::abs(x - m_dragStartX)), + static_cast(std::abs(y - m_dragStartY))); + m_dragMoved = true; + return true; + } + case PlotDragMode::SCROLL_LANES: + { + if (m_plotRect.Height <= 0.0f || m_laneContentHeight <= m_plotRect.Height) + { + return false; + } + const double travel = static_cast(y - m_dragLastY) / static_cast(m_plotRect.Height); + m_laneScroll += static_cast(travel * m_laneContentHeight); + ClampLaneScroll(); + m_dragLastY = y; + m_dragMoved = true; + return true; + } + case PlotDragMode::NONE: + default: + break; + } + + bool changed = false; + + const int lane = LaneAtY(y); + if (lane != m_hoverLane) + { + m_hoverLane = lane; + changed = true; + } + + const bool insidePlot = UiPointInRect(m_plotRect, fx, fy); + if (insidePlot && !m_cursorPinned) + { + const double time = XToTime(fx); + if (!m_cursorVisible || std::fabs(time - m_cursorTime) > 0.0) + { + m_cursorTime = time; + m_cursorVisible = true; + changed = true; + } + } + return changed; + } + + bool PlotView::OnLButtonDown(int x, int y) + { + if (m_document == nullptr || !m_document->IsLoaded()) + { + return false; + } + + m_dragStartX = x; + m_dragStartY = y; + m_dragLastX = x; + m_dragLastY = y; + m_dragMoved = false; + m_dragStartViewMin = m_viewMin; + m_dragStartViewMax = m_viewMax; + + if (m_scrollBarVisible && UiPointInRect(m_scrollBarRect, static_cast(x), static_cast(y))) + { + m_dragMode = PlotDragMode::SCROLL_LANES; + return true; + } + + const int laneIndex = LaneAtY(y); + if (laneIndex < 0) + { + m_dragMode = PlotDragMode::NONE; + return false; + } + + if (static_cast(x) < m_plotRect.X) + { + m_dragMode = PlotDragMode::ZOOM_Y; + m_dragLane = laneIndex; + + const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; + Lane& lane = m_document->Lanes()[static_cast(laneIndex)]; + lane.autoRange = false; + lane.yMin = visual.yMin; + lane.yMax = visual.yMax; + + m_dragStartYMin = visual.yMin; + m_dragStartYMax = visual.yMax; + m_dragAnchorValue = YToValue(static_cast(y), visual); + const double span = visual.yMax - visual.yMin; + m_dragAnchorRatio = (span > 0.0) ? (visual.yMax - m_dragAnchorValue) / span : 0.5; + return true; + } + + if (UiPointInRect(m_plotRect, static_cast(x), static_cast(y))) + { + m_dragMode = PlotDragMode::PAN_TIME; + return true; + } + + m_dragMode = PlotDragMode::NONE; + return false; + } + + bool PlotView::OnLButtonUp(int x, int y) + { + bool changed = (m_dragMode != PlotDragMode::NONE); + + if (m_dragMode == PlotDragMode::PAN_TIME && !m_dragMoved && + UiPointInRect(m_plotRect, static_cast(x), static_cast(y))) + { + const double time = XToTime(static_cast(x)); + if (m_cursorPinned && std::fabs(time - m_cursorTime) < 1.0e-12) + { + m_cursorPinned = false; + m_cursorVisible = false; + } + else + { + m_cursorTime = time; + m_cursorVisible = true; + m_cursorPinned = true; + } + changed = true; + } + + m_dragMode = PlotDragMode::NONE; + m_dragLane = -1; + m_dragMoved = false; + return changed; + } + + bool PlotView::OnRButtonDown(int x, int y) + { + if (m_document == nullptr || !m_document->IsLoaded()) + { + return false; + } + if (!UiPointInRect(m_plotRect, static_cast(x), static_cast(y))) + { + return false; + } + + m_dragMode = PlotDragMode::BOX_ZOOM; + m_dragStartX = x; + m_dragStartY = y; + m_dragLastX = x; + m_dragLastY = y; + m_dragMoved = false; + m_dragLane = LaneAtY(y); + m_boxRect = gd::RectF(static_cast(x), static_cast(y), 0.0f, 0.0f); + return true; + } + + bool PlotView::OnRButtonUp(int x, int y) + { + if (m_dragMode != PlotDragMode::BOX_ZOOM) + { + return false; + } + + const bool moved = m_dragMoved; + const int laneIndex = m_dragLane; + const gd::RectF box = m_boxRect; + + m_dragMode = PlotDragMode::NONE; + m_dragMoved = false; + m_dragLane = -1; + + if (!moved || box.Width < 6.0f * m_scale) + { + // 未形成有效框:清除已固定光标。 + if (!m_cursorPinned) + { + m_cursorVisible = false; + } + return true; + } + + double timeA = XToTime(box.X); + double timeB = XToTime(box.X + box.Width); + if (timeB < timeA) + { + std::swap(timeA, timeB); + } + if (timeB - timeA > 0.0) + { + m_viewMin = timeA; + m_viewMax = timeB; + ClampTimeView(); + } + + if (laneIndex >= 0 && laneIndex < static_cast(m_lanes.size()) && + box.Height > 12.0f * m_scale) + { + const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; + const double valueTop = YToValue(box.Y, visual); + const double valueBottom = YToValue(box.Y + box.Height, visual); + if (std::fabs(valueTop - valueBottom) > 0.0) + { + Lane& lane = m_document->Lanes()[static_cast(laneIndex)]; + lane.autoRange = false; + lane.yMin = std::min(valueTop, valueBottom); + lane.yMax = std::max(valueTop, valueBottom); + } + } + (void)y; + return true; + } + + bool PlotView::OnMouseWheel(int x, int y, int delta, bool ctrl, bool shift, bool alt) + { + if (m_document == nullptr || !m_document->IsLoaded() || delta == 0) + { + return false; + } + + const double notches = static_cast(delta) / 120.0; + + if (alt) + { + m_laneScroll -= static_cast(notches * 48.0 * m_scale); + ClampLaneScroll(); + return true; + } + + if (shift) + { + const double span = m_viewMax - m_viewMin; + const double sweep = -notches * span * 0.15; + m_viewMin += sweep; + m_viewMax += sweep; + ClampTimeView(); + return true; + } + + if (ctrl) + { + int laneIndex = LaneAtY(y); + if (laneIndex < 0) + { + laneIndex = m_hoverLane; + } + if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) + { + return false; + } + + const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; + Lane& lane = m_document->Lanes()[static_cast(laneIndex)]; + LaneVisual mutableVisual = visual; + if (lane.autoRange) + { + lane.autoRange = false; + } + lane.yMin = visual.yMin; + lane.yMax = visual.yMax; + + const double anchorValue = YToValue(static_cast(y), mutableVisual); + const double span = visual.yMax - visual.yMin; + if (!(span > 0.0)) + { + return false; + } + + const double factor = std::pow(1.25, notches); + double newSpan = span / factor; + const double scaleRef = std::max(std::fabs(visual.yMax), std::fabs(visual.yMin)); + const double minSpan = std::max(scaleRef * 1.0e-9, 1.0e-12); + newSpan = ClampDouble(newSpan, minSpan, scaleRef * 100.0 + 1.0); + + const double ratio = (visual.yMax - anchorValue) / span; + lane.yMin = anchorValue - ratio * newSpan; + lane.yMax = lane.yMin + newSpan; + return true; + } + + const double anchorTime = XToTime(static_cast(x)); + ZoomTimeAt(std::pow(1.25, notches), anchorTime); + return true; + } + + bool PlotView::OnDoubleClick(int x, int y) + { + if (!UiPointInRect(m_area, static_cast(x), static_cast(y))) + { + return false; + } + FitAll(); + return true; + } + + bool PlotView::OnMouseLeave() + { + bool changed = false; + if (m_hoverLane != -1) + { + m_hoverLane = -1; + changed = true; + } + if (!m_cursorPinned && m_cursorVisible) + { + m_cursorVisible = false; + changed = true; + } + return changed; + } + + void PlotView::BuildPolyline(const Signal& signal, const LaneVisual& lane, bool stepMode, + std::vector& points, std::vector& breaks) const + { + points.clear(); + breaks.clear(); + + if (m_document == nullptr) + { + return; + } + + const std::vector& time = m_document->Time(); + const std::vector& values = signal.values; + const std::size_t count = std::min(time.size(), values.size()); + if (count == 0 || m_plotRect.Width <= 1.0f) + { + return; + } + + std::size_t begin = 0; + std::size_t end = count; + + if (m_document->TimeMonotonic()) + { + const std::vector::const_iterator first = + std::lower_bound(time.begin(), time.end(), m_viewMin); + begin = static_cast(first - time.begin()); + if (begin > 0) + { + --begin; + } + const std::vector::const_iterator last = std::lower_bound(time.begin(), time.end(), m_viewMax); + end = static_cast(last - time.begin()); + if (end < count) + { + ++end; + } + } + + if (end <= begin) + { + return; + } + + std::size_t stride = 1; + const std::size_t visibleCount = end - begin; + if (visibleCount > kMaxPolylinePoints) + { + stride = (visibleCount + kMaxPolylinePoints - 1) / kMaxPolylinePoints; + } + + const float limitTop = lane.rect.Y - lane.rect.Height * 4.0f; + const float limitBottom = lane.rect.Y + lane.rect.Height * 5.0f; + + bool inSegment = false; + double previousY = 0.0; + + for (std::size_t index = begin; index < end; index += stride) + { + const double t = time[index]; + const double value = values[index]; + if (!std::isfinite(t) || !std::isfinite(value)) + { + inSegment = false; + continue; + } + + const float x = TimeToX(t); + float y = ValueToY(value, lane); + y = ClampDouble(y, limitTop, limitBottom); + + if (!inSegment) + { + breaks.push_back(points.size()); + points.push_back(gd::PointF(x, y)); + inSegment = true; + } + else if (stepMode) + { + points.push_back(gd::PointF(x, static_cast(previousY))); + points.push_back(gd::PointF(x, y)); + } + else + { + points.push_back(gd::PointF(x, y)); + } + + previousY = y; + } + } + + void PlotView::Paint(gd::Graphics& g) + { + if (m_theme == nullptr || m_fonts == nullptr) + { + return; + } + + UiFillRect(g, m_area, m_theme->plotBg); + + if (m_document == nullptr || !m_document->IsLoaded()) + { + UiText(g, L"打开 CSV 文件以查看仿真数据波形", m_fonts->ui, m_area, m_theme->textMuted, + TextAlign::CENTER); + return; + } + + BuildLaneVisuals(); + + const double span = m_viewMax - m_viewMin; + const double timeStep = NiceStep((span > 0.0) ? span : 1.0, kTimeTickTarget); + std::vector timeTicks; + BuildTicks(m_viewMin, m_viewMax, timeStep, timeTicks); + + // 波形区域边框。 + UiFillRect(g, m_plotRect, m_theme->cardBg); + + for (std::size_t index = 0; index < m_lanes.size(); ++index) + { + PaintLane(g, static_cast(index), m_lanes[index], timeTicks, timeStep); + } + + PaintTimeAxis(g, timeTicks, timeStep); + PaintCursor(g, m_lanes); + + if (m_dragMode == PlotDragMode::BOX_ZOOM && m_dragMoved) + { + UiFillRect(g, m_boxRect, WithAlpha(m_theme->accent, 0x2A)); + UiDrawRoundRect(g, m_boxRect, 2.0f * m_scale, m_theme->accent, 1.0f); + } + + PaintScrollBar(g); + + // 绘图区外框。 + gd::Pen borderPen(ToGdiColor(m_theme->border), 1.0f); + g.DrawRectangle(&borderPen, m_plotRect.X, m_plotRect.Y, m_plotRect.Width, m_plotRect.Height); + } + + void PlotView::PaintLane(gd::Graphics& g, int laneIndex, const LaneVisual& lane, + const std::vector& timeTicks, double timeStep) + { + const int saveState = g.Save(); + g.SetClip(gd::RectF(m_area.X, lane.rect.Y, m_area.Width, lane.rect.Height)); + + // 悬停高亮。 + if (laneIndex == m_hoverLane && m_dragMode == PlotDragMode::NONE) + { + UiFillRect(g, lane.rect, MixColor(m_theme->cardBg, m_theme->accent, 0.035)); + } + + // 幅值网格线与标签。 + const double yStep = NiceStep(lane.yMax - lane.yMin, kYTickTarget); + std::vector yTicks; + BuildTicks(lane.yMin, lane.yMax, yStep, yTicks); + + const bool showLabels = (lane.rect.Height >= 34.0f * m_scale); + float lastLabelY = -1.0e9f; + + for (const double tick : yTicks) + { + const float y = ValueToY(tick, lane); + if (y < lane.rect.Y - 1.0f || y > lane.rect.Y + lane.rect.Height + 1.0f) + { + continue; + } + + const bool zeroLine = (tick == 0.0); + UiDrawLine(g, lane.rect.X, y, lane.rect.X + lane.rect.Width, y, + zeroLine ? m_theme->gridStrong : m_theme->gridWeak, 1.0f); + + if (!showLabels || std::fabs(y - lastLabelY) < 15.0f * m_scale) + { + continue; + } + lastLabelY = y; + + const gd::RectF labelRect(m_area.X, y - 9.0f * m_scale, m_yAxisWidth - kAxisLabelGap * m_scale, + 18.0f * m_scale); + UiText(g, FormatTick(tick, yStep), m_fonts->monoSmall, labelRect, + zeroLine ? m_theme->text : m_theme->textMuted, TextAlign::RIGHT); + } + + // 时间网格线。 + for (const double tick : timeTicks) + { + const float x = TimeToX(tick); + if (x < lane.rect.X || x > lane.rect.X + lane.rect.Width) + { + continue; + } + UiDrawLine(g, x, lane.rect.Y, x, lane.rect.Y + lane.rect.Height, m_theme->gridWeak, 1.0f); + } + + // 波形绘制。 + if (m_document->Lanes()[static_cast(laneIndex)].signalIndices.empty()) + { + UiText(g, L"(空网格)", m_fonts->uiSmall, lane.rect, m_theme->textMuted, TextAlign::CENTER); + } + else + { + const int waveSave = g.Save(); + g.SetClip(lane.rect, gd::CombineModeIntersect); + + std::vector points; + std::vector breaks; + + for (const int signalIndex : m_document->Lanes()[static_cast(laneIndex)].signalIndices) + { + if (signalIndex < 0 || signalIndex >= static_cast(m_document->Signals().size())) + { + continue; + } + const Signal& signal = m_document->Signals()[static_cast(signalIndex)]; + if (!signal.visible) + { + continue; + } + + const bool stepMode = (signal.mode == DrawMode::STEP); + BuildPolyline(signal, lane, stepMode, points, breaks); + + const bool drawLine = (signal.mode != DrawMode::SCATTER); + const bool drawScatter = (signal.mode == DrawMode::SCATTER || signal.mode == DrawMode::LINE_SCATTER); + + if (drawLine && !breaks.empty()) + { + gd::Pen pen(ToGdiColor(signal.color), signal.lineWidth * m_scale); + pen.SetLineJoin(stepMode ? gd::LineJoinMiter : gd::LineJoinRound); + pen.SetStartCap(gd::LineCapRound); + pen.SetEndCap(gd::LineCapRound); + + for (std::size_t k = 0; k < breaks.size(); ++k) + { + const std::size_t start = breaks[k]; + const std::size_t stop = (k + 1 < breaks.size()) ? breaks[k + 1] : points.size(); + if (stop <= start + 1) + { + continue; + } + g.DrawLines(&pen, points.data() + start, static_cast(stop - start)); + } + } + + if (drawScatter && !points.empty()) + { + const float radius = static_cast( + ClampDouble(std::max(1.0f, signal.lineWidth) * (kMinScatterRadius + 0.6f), + kMinScatterRadius, kMaxScatterRadius) * + m_scale); + const std::size_t scatterStride = std::max(1, points.size() / kMaxScatterPoints); + gd::SolidBrush brush(ToGdiColor(signal.color)); + for (std::size_t k = 0; k < points.size(); k += scatterStride) + { + g.FillEllipse(&brush, points[k].X - radius, points[k].Y - radius, radius * 2.0f, + radius * 2.0f); + } + } + } + + g.Restore(waveSave); + } + + // 波形区边框与网格标题。 + UiDrawLine(g, lane.rect.X, lane.rect.Y + lane.rect.Height, lane.rect.X + lane.rect.Width, + lane.rect.Y + lane.rect.Height, m_theme->gridStrong, 1.0f); + UiDrawLine(g, m_area.X, lane.rect.Y, m_area.X + m_area.Width, lane.rect.Y, m_theme->border, 1.0f); + + if (lane.rect.Height >= 24.0f * m_scale) + { + PaintLaneLegend(g, laneIndex, lane); + if (m_cursorVisible && !lane.empty && lane.rect.Height >= 40.0f * m_scale) + { + PaintLaneReadout(g, laneIndex, lane); + } + } + + g.Restore(saveState); + (void)timeStep; + } + + void PlotView::PaintLaneLegend(gd::Graphics& g, int laneIndex, const LaneVisual& lane) + { + const std::vector& lanes = m_document->Lanes(); + if (laneIndex < 0 || laneIndex >= static_cast(lanes.size())) + { + return; + } + + const float top = lane.rect.Y + 4.0f * m_scale; + const float height = 18.0f * m_scale; + float cursorX = lane.rect.X + kLanePadding * m_scale; + const float maxX = lane.rect.X + lane.rect.Width - kLanePadding * m_scale; + + bool anyVisible = false; + int visibleCount = 0; + int drawnCount = 0; + + // 先测量图例总宽度,再用半透明底板掠开底下的网格线,提升可读性。 + float legendWidth = 0.0f; + for (const int signalIndex : lanes[static_cast(laneIndex)].signalIndices) + { + if (signalIndex < 0 || signalIndex >= static_cast(m_document->Signals().size())) + { + continue; + } + const Signal& signal = m_document->Signals()[static_cast(signalIndex)]; + if (!signal.visible) + { + continue; + } + + std::wstring label = signal.name; + if (!signal.unit.empty()) + { + label += L" [" + signal.unit + L"]"; + } + const float itemWidth = 8.0f * m_scale + 5.0f * m_scale + + UiTextWidth(g, label, m_fonts->uiSmall) + 14.0f * m_scale; + if (cursorX + legendWidth + itemWidth > maxX) + { + break; + } + legendWidth += itemWidth; + } + if (legendWidth > 0.0f) + { + UiFillRoundRect(g, gd::RectF(cursorX - 5.0f * m_scale, top - 1.0f * m_scale, + legendWidth + 6.0f * m_scale, height + 2.0f * m_scale), + 3.0f * m_scale, WithAlpha(m_theme->cardBg, 0xD8)); + } + + // 绘制图例条目(与测量阶段保持一致的裁剪条件)。 + float drawnWidth = 0.0f; + for (const int signalIndex : lanes[static_cast(laneIndex)].signalIndices) + { + if (signalIndex < 0 || signalIndex >= static_cast(m_document->Signals().size())) + { + continue; + } + const Signal& signal = m_document->Signals()[static_cast(signalIndex)]; + if (!signal.visible) + { + continue; + } + anyVisible = true; + ++visibleCount; + + std::wstring label = signal.name; + if (!signal.unit.empty()) + { + label += L" [" + signal.unit + L"]"; + } + + const float textWidth = UiTextWidth(g, label, m_fonts->uiSmall); + const float itemWidth = 8.0f * m_scale + 5.0f * m_scale + textWidth + 14.0f * m_scale; + if (drawnWidth + itemWidth > legendWidth + 0.5f) + { + break; + } + drawnWidth += itemWidth; + + const float swatchSize = 8.0f * m_scale; + UiFillRoundRect(g, gd::RectF(cursorX, top + (height - swatchSize) * 0.5f, swatchSize, swatchSize), + 1.5f * m_scale, signal.color); + cursorX += swatchSize + 5.0f * m_scale; + + UiText(g, label, m_fonts->uiSmall, gd::RectF(cursorX, top, textWidth + 2.0f, height), m_theme->text, + TextAlign::LEFT); + cursorX += textWidth + 14.0f * m_scale; + ++drawnCount; + } + + if (!anyVisible) + { + UiText(g, L"(无可见信号)", m_fonts->uiSmall, + gd::RectF(lane.rect.X + kLanePadding * m_scale, top, lane.rect.Width, height), + m_theme->textMuted, TextAlign::LEFT); + } + else if (drawnCount < visibleCount) + { + // 图例空间不足时提示剩余信号数量,避免误认为信号丢失。 + const std::wstring moreText = L"+" + std::to_wstring(visibleCount - drawnCount); + if (cursorX + 20.0f * m_scale <= maxX) + { + UiText(g, moreText, m_fonts->uiSmall, gd::RectF(cursorX, top, 24.0f * m_scale, height), + m_theme->textMuted, TextAlign::LEFT); + } + } + } + + void PlotView::PaintLaneReadout(gd::Graphics& g, int laneIndex, const LaneVisual& lane) + { + const std::vector& lanes = m_document->Lanes(); + if (laneIndex < 0 || laneIndex >= static_cast(lanes.size())) + { + return; + } + + if (m_cursorTime < m_viewMin || m_cursorTime > m_viewMax) + { + return; + } + + struct ReadoutLine + { + std::wstring text; + std::uint32_t color; + float value; + }; + + std::vector lines; + for (const int signalIndex : lanes[static_cast(laneIndex)].signalIndices) + { + if (signalIndex < 0 || signalIndex >= static_cast(m_document->Signals().size())) + { + continue; + } + const Signal& signal = m_document->Signals()[static_cast(signalIndex)]; + if (!signal.visible) + { + continue; + } + + const bool interpolate = (signal.mode != DrawMode::SCATTER); + double value = 0.0; + if (!m_document->SampleAt(signalIndex, m_cursorTime, interpolate, value)) + { + continue; + } + + ReadoutLine line; + line.text = signal.name + L" = " + FormatValue(value); + line.color = signal.color; + line.value = ValueToY(value, lane); + lines.push_back(std::move(line)); + } + + if (lines.empty()) + { + return; + } + + const float lineHeight = 17.0f * m_scale; + const float maxLines = std::max(1.0f, std::floor((lane.rect.Height - 12.0f * m_scale) / lineHeight)); + const std::size_t shown = std::min(lines.size(), static_cast(maxLines)); + + float boxWidth = 0.0f; + for (std::size_t index = 0; index < shown; ++index) + { + boxWidth = std::max(boxWidth, UiTextWidth(g, lines[index].text, m_fonts->monoSmall)); + } + boxWidth += 20.0f * m_scale; + + const float boxHeight = static_cast(shown) * lineHeight + 8.0f * m_scale; + const gd::RectF box(lane.rect.X + lane.rect.Width - kLanePadding * m_scale - boxWidth, + lane.rect.Y + 4.0f * m_scale, boxWidth, boxHeight); + + UiFillRoundRect(g, box, 4.0f * m_scale, WithAlpha(m_theme->readoutBg, 0xEE)); + UiDrawRoundRect(g, box, 4.0f * m_scale, m_theme->border, 1.0f); + + float textY = box.Y + 4.0f * m_scale; + for (std::size_t index = 0; index < shown; ++index) + { + const gd::RectF lineRect(box.X + 10.0f * m_scale, textY, boxWidth - 14.0f * m_scale, lineHeight); + UiText(g, lines[index].text, m_fonts->monoSmall, lineRect, m_theme->textStrong, TextAlign::LEFT); + textY += lineHeight; + } + } + + void PlotView::PaintTimeAxis(gd::Graphics& g, const std::vector& timeTicks, double timeStep) + { + const float axisY = m_plotRect.Y + m_plotRect.Height; + UiDrawLine(g, m_plotRect.X, axisY, m_plotRect.X + m_plotRect.Width, axisY, m_theme->axisLine, 1.0f); + + for (const double tick : timeTicks) + { + const float x = TimeToX(tick); + if (x < m_plotRect.X - 1.0f || x > m_plotRect.X + m_plotRect.Width + 1.0f) + { + continue; + } + + UiDrawLine(g, x, axisY, x, axisY + 4.0f * m_scale, m_theme->axisLine, 1.0f); + + const gd::RectF labelRect(x - 48.0f * m_scale, axisY + 4.0f * m_scale, 96.0f * m_scale, + m_timeAxisRect.Height - 6.0f * m_scale); + UiText(g, FormatTick(tick, timeStep), m_fonts->monoSmall, labelRect, m_theme->textMuted, + TextAlign::CENTER); + } + + // 时间轴列名(过长时自动省略)。 + const std::wstring axisName = + m_document->TimeColumnName().empty() ? std::wstring(L"时间") : m_document->TimeColumnName(); + UiText(g, axisName, m_fonts->monoSmall, + gd::RectF(m_area.X, axisY, m_yAxisWidth - kAxisLabelGap * m_scale, m_timeAxisRect.Height), + m_theme->textMuted, TextAlign::RIGHT); + } + + void PlotView::PaintCursor(gd::Graphics& g, const std::vector& lanes) + { + if (!m_cursorVisible) + { + return; + } + + const float x = TimeToX(m_cursorTime); + if (x < m_plotRect.X - 2.0f || x > m_plotRect.X + m_plotRect.Width + 2.0f) + { + return; + } + + UiDrawDashedLine(g, x, m_plotRect.Y, x, m_plotRect.Y + m_plotRect.Height, m_theme->cursor, + 1.0f, m_cursorPinned ? gd::DashStyleSolid : gd::DashStyleDash); + + // 波形上的取值标记。 + for (std::size_t laneIndex = 0; laneIndex < lanes.size(); ++laneIndex) + { + const LaneVisual& lane = lanes[laneIndex]; + if (lane.empty) + { + continue; + } + const std::vector& indices = m_document->Lanes()[laneIndex].signalIndices; + for (const int signalIndex : indices) + { + if (signalIndex < 0 || signalIndex >= static_cast(m_document->Signals().size())) + { + continue; + } + const Signal& signal = m_document->Signals()[static_cast(signalIndex)]; + if (!signal.visible) + { + continue; + } + + double value = 0.0; + if (!m_document->SampleAt(signalIndex, m_cursorTime, signal.mode != DrawMode::SCATTER, value)) + { + continue; + } + + const float y = ValueToY(value, lane); + if (y < lane.rect.Y - 2.0f || y > lane.rect.Y + lane.rect.Height + 2.0f) + { + continue; + } + + const float radius = 3.4f * m_scale; + gd::SolidBrush ring(ToGdiColor(m_theme->cardBg)); + gd::SolidBrush fill(ToGdiColor(signal.color)); + g.FillEllipse(&ring, x - radius - 1.2f * m_scale, y - radius - 1.2f * m_scale, + (radius + 1.2f * m_scale) * 2.0f, (radius + 1.2f * m_scale) * 2.0f); + g.FillEllipse(&fill, x - radius, y - radius, radius * 2.0f, radius * 2.0f); + } + } + + // 时间轴上的光标标签。 + const std::wstring label = FormatValue(m_cursorTime); + const float textWidth = UiTextWidth(g, label, m_fonts->monoSmall); + const float pillWidth = textWidth + 14.0f * m_scale; + const float pillHeight = 19.0f * m_scale; + float pillX = x - pillWidth * 0.5f; + pillX = std::max(m_plotRect.X, std::min(pillX, m_plotRect.X + m_plotRect.Width - pillWidth)); + + const gd::RectF pill(pillX, m_plotRect.Y + m_plotRect.Height + 2.0f * m_scale, pillWidth, pillHeight); + UiFillRoundRect(g, pill, 3.0f * m_scale, m_theme->cursor); + UiText(g, label, m_fonts->monoSmall, pill, m_theme->textOnAccent, TextAlign::CENTER); + } + + void PlotView::PaintScrollBar(gd::Graphics& g) + { + if (!m_scrollBarVisible || m_laneContentHeight <= m_plotRect.Height) + { + return; + } + + UiFillRoundRect(g, m_scrollBarRect, kScrollBarThickness * m_scale * 0.5f, m_theme->gridWeak); + + const float ratio = std::min(1.0f, m_plotRect.Height / m_laneContentHeight); + const float thumbHeight = std::max(24.0f * m_scale, m_scrollBarRect.Height * ratio); + const float maxScroll = m_laneContentHeight - m_plotRect.Height; + const float progress = (maxScroll > 0.0f) ? (m_laneScroll / maxScroll) : 0.0f; + const float thumbY = m_scrollBarRect.Y + progress * (m_scrollBarRect.Height - thumbHeight); + + const gd::RectF thumb(m_scrollBarRect.X, thumbY, m_scrollBarRect.Width, thumbHeight); + UiFillRoundRect(g, thumb, kScrollBarThickness * m_scale * 0.5f, m_theme->borderStrong); + } + +} // namespace jdi diff --git a/Components/SignalPanel/Inc/SignalPanel.hpp b/Components/SignalPanel/Inc/SignalPanel.hpp new file mode 100644 index 0000000..6c56d8c --- /dev/null +++ b/Components/SignalPanel/Inc/SignalPanel.hpp @@ -0,0 +1,218 @@ +#ifndef __SIGNAL_PANEL_HPP__ +#define __SIGNAL_PANEL_HPP__ + +#include +#include +#include + +#include +#include + +#include "PaintUtil.hpp" +#include "SignalSet.hpp" +#include "Theme.hpp" + +namespace jdi +{ + + /** + * @brief 左侧信号面板:信号列表、可见性开关与样式编辑。 + */ + class SignalPanel + { + public: + SignalPanel(); + + /** + * @brief 绑定宿主窗口、字体与主题资源。 + * @param [IN] host 宿主窗口句柄,用于弹出上下文菜单。 + * @param [IN] fonts 字体集合,需在面板生命周期内保持有效。 + * @param [IN] theme 主题配色,需在面板生命周期内保持有效。 + */ + void Setup(HWND host, const UiFonts* fonts, const Theme* theme); + + /** + * @brief 绑定数据集。 + * @param [IN] document 数据集指针,允许为空。 + */ + void SetDocument(SignalSet* document); + + /** + * @brief 设置 DPI 缩放比例。 + * @param [IN] scale 缩放比例(1.0 对应 96 DPI)。 + */ + void SetScale(float scale); + + /** + * @brief 按给定区域重新排布面板内容。 + * @param [IN] area 面板可用区域。 + */ + void Layout(const gd::RectF& area); + + /** + * @brief 绘制面板。 + * @param [IN,OUT] g 绘图上下文。 + */ + void Paint(gd::Graphics& g); + + /** + * @brief 处理鼠标移动。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @return 悬停状态发生变化返回 true。 + */ + bool OnMouseMove(int x, int y); + + /** + * @brief 处理鼠标左键按下。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @return 需要重绘返回 true。 + */ + bool OnLButtonDown(int x, int y); + + /** + * @brief 处理鼠标左键抬起。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @return 视觉状态发生变化返回 true。 + */ + bool OnLButtonUp(int x, int y); + + /** + * @brief 处理鼠标左键双击(切换信号可见性)。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @return 需要重绘返回 true。 + */ + bool OnDoubleClick(int x, int y); + + /** + * @brief 处理鼠标右键按下(弹出信号上下文菜单)。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @return 需要重绘返回 true。 + */ + bool OnRButtonDown(int x, int y); + + /** + * @brief 处理滚轮滚动(列表纵向滚动)。 + * @param [IN] delta 滚轮增量。 + * @return 需要重绘返回 true。 + */ + bool OnMouseWheel(int delta); + + /** + * @brief 处理鼠标离开面板。 + * @return 视觉状态发生变化返回 true。 + */ + bool OnMouseLeave(); + + /** + * @brief 判断坐标是否落在面板区域内。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @return 落在区域内返回 true。 + */ + bool HitTest(int x, int y) const; + + private: + /** + * @brief 样式编辑区中的可点击条目类型。 + */ + enum class StyleHit : int + { + NONE = 0, /**< 未命中。 */ + COLOR, /**< 颜色色块。 */ + WIDTH, /**< 线宽按钮。 */ + MODE, /**< 绘制方式按钮。 */ + LANE, /**< 网格选择按钮。 */ + TOGGLE_ALL /**< 全部显示/隐藏按钮。 */ + }; + + /** + * @brief 计算列表区域内容高度。 + * @return 内容高度。 + */ + float ContentHeight() const; + + /** + * @brief 限制滚动偏移量到合法范围。 + */ + void ClampScroll(); + + /** + * @brief 查询指定坐标对应的信号行下标。 + * @param [IN] y 客户区纵坐标。 + * @return 行下标,未命中返回 -1。 + */ + int RowAtY(int y) const; + + /** + * @brief 计算行的纵向矩形。 + * @param [IN] row 行下标。 + * @return 行矩形。 + */ + gd::RectF RowRect(int row) const; + + /** + * @brief 绘制列表区域的单行。 + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] row 行下标。 + */ + void PaintRow(gd::Graphics& g, int row); + + /** + * @brief 绘制样式编辑区。 + * @param [IN,OUT] g 绘图上下文。 + */ + void PaintStyleSection(gd::Graphics& g); + + /** + * @brief 弹出信号右键菜单。 + * @param [IN] signalIndex 目标信号下标。 + * @param [IN] screenX 屏幕坐标横坐标。 + * @param [IN] screenY 屏幕坐标纵坐标。 + */ + void ShowSignalMenu(int signalIndex, int screenX, int screenY); + + /** + * @brief 弹出网格分配菜单。 + */ + void ShowLaneMenu(); + + /** + * @brief 判断全部信号是否均为可见状态。 + * @return 全部可见返回 true。 + */ + bool AreAllSignalsVisible() const; + + HWND m_host = nullptr; /**< 宿主窗口句柄。 */ + const UiFonts* m_fonts = nullptr; /**< 字体资源。 */ + const Theme* m_theme = nullptr; /**< 主题资源。 */ + SignalSet* m_document = nullptr; /**< 数据集。 */ + float m_scale = 1.0f; /**< DPI 缩放比例。 */ + + gd::RectF m_area; /**< 面板整体区域。 */ + gd::RectF m_headerRect; /**< 标题区域。 */ + gd::RectF m_listRect; /**< 列表区域。 */ + gd::RectF m_styleRect; /**< 样式编辑区域。 */ + gd::RectF m_toggleAllRect; /**< 全部显示/隐藏按钮。 */ + gd::RectF m_laneButtonRect; /**< 网格选择按钮。 */ + + std::array m_colorRects; /**< 颜色色块矩形。 */ + std::array m_widthRects; /**< 线宽按钮矩形。 */ + std::array m_modeRects; /**< 绘制方式按钮矩形。 */ + int m_colorCount = 0; /**< 实际可用的颜色数量。 */ + float m_widthRowLabelX = 0.0f; /**< 样式标签列起始横坐标。 */ + float m_styleRowHeight = 0.0f; /**< 样式行高。 */ + + float m_scrollOffset = 0.0f; /**< 列表滚动偏移量。 */ + int m_hoverRow = -1; /**< 当前悬停行。 */ + StyleHit m_hoverStyle = StyleHit::NONE; /**< 当前悬停的样式条目类型。 */ + int m_hoverStyleIndex = -1; /**< 当前悬停的样式条目下标。 */ + }; + +} // namespace jdi + +#endif // __SIGNAL_PANEL_HPP__ diff --git a/Components/SignalPanel/Src/SignalPanel.cpp b/Components/SignalPanel/Src/SignalPanel.cpp new file mode 100644 index 0000000..b14f646 --- /dev/null +++ b/Components/SignalPanel/Src/SignalPanel.cpp @@ -0,0 +1,889 @@ +#include "SignalPanel.hpp" + +#include +#include + +#include "Utf8Util.hpp" + +namespace jdi +{ + + namespace + { + const float kHeaderHeight = 36.0f; /**< 标题区高度。 */ + const float kRowHeight = 30.0f; /**< 列表行高。 */ + const float kStyleHeight = 182.0f; /**< 样式编辑区高度。 */ + const float kPadding = 12.0f; /**< 通用内边距。 */ + const float kCheckSize = 15.0f; /**< 复选框尺寸。 */ + const float kSwatchSize = 12.0f; /**< 颜色小方块尺寸。 */ + const float kBadgeWidth = 30.0f; /**< 网格标签宽度。 */ + const float kBadgeHeight = 17.0f; /**< 网格标签高度。 */ + const float kCorner = 4.0f; /**< 通用圆角半径。 */ + const float kLineLabelWidth = 40.0f; /**< 样式行标签列宽度。 */ + + /** @brief 上下文菜单命令 ID 基准值。 */ + const int kMenuToggleVisible = 1; + const int kMenuSolo = 2; + const int kMenuShowAll = 3; + const int kMenuModeBase = 100; + const int kMenuWidthBase = 200; + const int kMenuColorBase = 300; + const int kMenuLaneBase = 400; + const int kMenuLaneNew = 500; + + /** @brief 可选线宽档位。 */ + const float kWidthOptions[] = {1.0f, 1.5f, 2.0f, 3.0f}; + + /** @brief 可选绘制方式顺序。 */ + const DrawMode kModeOptions[] = {DrawMode::LINE, DrawMode::SCATTER, DrawMode::LINE_SCATTER, DrawMode::STEP}; + } + + SignalPanel::SignalPanel() + { + m_colorRects.fill(gd::RectF()); + m_widthRects.fill(gd::RectF()); + m_modeRects.fill(gd::RectF()); + } + + void SignalPanel::Setup(HWND host, const UiFonts* fonts, const Theme* theme) + { + m_host = host; + m_fonts = fonts; + m_theme = theme; + } + + void SignalPanel::SetDocument(SignalSet* document) + { + m_document = document; + m_scrollOffset = 0.0f; + } + + void SignalPanel::SetScale(float scale) + { + m_scale = (scale > 0.1f) ? scale : 1.0f; + } + + bool SignalPanel::HitTest(int x, int y) const + { + return UiPointInRect(m_area, static_cast(x), static_cast(y)); + } + + float SignalPanel::ContentHeight() const + { + if (m_document == nullptr) + { + return 0.0f; + } + return static_cast(m_document->Signals().size()) * kRowHeight * m_scale; + } + + void SignalPanel::ClampScroll() + { + const float visibleHeight = m_listRect.Height; + const float contentHeight = ContentHeight(); + const float maxOffset = std::max(0.0f, contentHeight - visibleHeight); + m_scrollOffset = std::max(0.0f, std::min(m_scrollOffset, maxOffset)); + } + + int SignalPanel::RowAtY(int y) const + { + if (m_document == nullptr || m_listRect.Height <= 0.0f) + { + return -1; + } + + const float fy = static_cast(y); + if (fy < m_listRect.Y || fy > m_listRect.Y + m_listRect.Height) + { + return -1; + } + + const float local = fy - m_listRect.Y + m_scrollOffset; + if (local < 0.0f) + { + return -1; + } + + const int row = static_cast(local / (kRowHeight * m_scale)); + if (row < 0 || row >= static_cast(m_document->Signals().size())) + { + return -1; + } + return row; + } + + gd::RectF SignalPanel::RowRect(int row) const + { + const float rowHeight = kRowHeight * m_scale; + const float top = m_listRect.Y + static_cast(row) * rowHeight - m_scrollOffset; + return gd::RectF(m_listRect.X, top, m_listRect.Width, rowHeight); + } + + void SignalPanel::Layout(const gd::RectF& area) + { + m_area = area; + + m_headerRect = gd::RectF(area.X, area.Y, area.Width, kHeaderHeight * m_scale); + + const float styleHeight = kStyleHeight * m_scale; + m_styleRect = gd::RectF(area.X, area.Y + area.Height - styleHeight, area.Width, styleHeight); + m_listRect = gd::RectF(area.X, m_headerRect.Y + m_headerRect.Height, + area.Width, std::max(0.0f, m_styleRect.Y - m_headerRect.Y - m_headerRect.Height)); + + // 标题区右侧的“全部显示/隐藏”按钮。 + const float toggleWidth = 68.0f * m_scale; + const float toggleHeight = 22.0f * m_scale; + m_toggleAllRect = gd::RectF(area.X + area.Width - kPadding * m_scale - toggleWidth, + m_headerRect.Y + (m_headerRect.Height - toggleHeight) * 0.5f, + toggleWidth, toggleHeight); + + // 样式编辑区布局。 + const float labelWidth = kLineLabelWidth * m_scale; + m_widthRowLabelX = m_styleRect.X + kPadding * m_scale + labelWidth; + m_styleRowHeight = 30.0f * m_scale; + + const float contentWidth = m_styleRect.Width - kPadding * 2.0f * m_scale - labelWidth; + const float contentLeft = m_widthRowLabelX; + + int paletteCount = 0; + const std::uint32_t* palette = SignalPalette(paletteCount); + (void)palette; + + m_colorCount = std::min(paletteCount, static_cast(m_colorRects.size())); + + // 色块尺寸按可用宽度自适应,避免超出面板。 + const float colorGap = 4.0f * m_scale; + float swatchSize = 20.0f * m_scale; + if (m_colorCount > 1) + { + const float fitted = (contentWidth - colorGap * static_cast(m_colorCount - 1)) / + static_cast(m_colorCount); + if (fitted < swatchSize) + { + swatchSize = std::max(10.0f * m_scale, fitted); + } + } + const float colorRowTop = m_styleRect.Y + 30.0f * m_scale; + for (int index = 0; index < m_colorCount; ++index) + { + const float x = contentLeft + static_cast(index) * (swatchSize + colorGap); + m_colorRects[static_cast(index)] = + gd::RectF(x, colorRowTop, swatchSize, swatchSize); + } + + const float widthRowTop = colorRowTop + swatchSize + 12.0f * m_scale; + const float widthGap = 6.0f * m_scale; + const float widthButtonWidth = std::min(56.0f * m_scale, + (contentWidth - widthGap * 3.0f) / 4.0f); + for (int index = 0; index < 4; ++index) + { + const float x = contentLeft + static_cast(index) * (widthButtonWidth + widthGap); + m_widthRects[static_cast(index)] = + gd::RectF(x, widthRowTop, widthButtonWidth, 26.0f * m_scale); + } + + const float modeRowTop = widthRowTop + 26.0f * m_scale + 10.0f * m_scale - 2.0f * m_scale; + const float modeGap = 6.0f * m_scale; + const float modeButtonWidth = (contentWidth - modeGap * 3.0f) / 4.0f; + for (int index = 0; index < 4; ++index) + { + const float x = contentLeft + static_cast(index) * (modeButtonWidth + modeGap); + m_modeRects[static_cast(index)] = + gd::RectF(x, modeRowTop, modeButtonWidth, 26.0f * m_scale); + } + + const float laneRowTop = modeRowTop + 26.0f * m_scale + 14.0f * m_scale; + m_laneButtonRect = gd::RectF(contentLeft, laneRowTop, + std::min(contentWidth, 150.0f * m_scale), 26.0f * m_scale); + + ClampScroll(); + } + + void SignalPanel::Paint(gd::Graphics& g) + { + if (m_theme == nullptr || m_fonts == nullptr) + { + return; + } + + UiFillRect(g, m_area, m_theme->panelBg); + + // 标题区。 + { + const std::wstring title = + (m_document == nullptr) + ? std::wstring(L"信号") + : (L"信号 · " + FormatCount(static_cast(m_document->Signals().size()))); + + gd::RectF titleRect(m_headerRect.X + kPadding * m_scale, m_headerRect.Y, + m_headerRect.Width - kPadding * 2.0f * m_scale, m_headerRect.Height); + UiText(g, title, m_fonts->uiBold, titleRect, m_theme->textStrong, TextAlign::LEFT); + + const bool allVisible = AreAllSignalsVisible(); + const std::wstring toggleLabel = allVisible ? L"全部隐藏" : L"全部显示"; + const bool toggleHover = (m_hoverStyle == StyleHit::TOGGLE_ALL); + if (toggleHover) + { + UiFillRoundRect(g, m_toggleAllRect, kCorner * m_scale, m_theme->hover); + } + UiDrawRoundRect(g, m_toggleAllRect, kCorner * m_scale, m_theme->border, 1.0f); + UiText(g, toggleLabel, m_fonts->uiSmall, m_toggleAllRect, + (m_document == nullptr) ? m_theme->textMuted : m_theme->text, TextAlign::CENTER); + } + + // 列表区。 + { + const int saveState = g.Save(); + g.SetClip(gd::RectF(m_listRect.X, m_listRect.Y, m_listRect.Width, m_listRect.Height)); + + if (m_document == nullptr || m_document->Signals().empty()) + { + UiText(g, L"尚未加载数据,请打开 CSV 文件", m_fonts->uiSmall, m_listRect, m_theme->textMuted, + TextAlign::CENTER); + } + else + { + const int rowCount = static_cast(m_document->Signals().size()); + for (int row = 0; row < rowCount; ++row) + { + const gd::RectF rect = RowRect(row); + if (rect.Y + rect.Height < m_listRect.Y || rect.Y > m_listRect.Y + m_listRect.Height) + { + continue; + } + PaintRow(g, row); + } + } + + g.Restore(saveState); + } + + PaintStyleSection(g); + + // 分隔线。 + UiDrawLine(g, m_area.X + m_area.Width - 1.0f, m_area.Y, m_area.X + m_area.Width - 1.0f, + m_area.Y + m_area.Height, m_theme->border, 1.0f); + UiDrawLine(g, m_headerRect.X, m_headerRect.Y + m_headerRect.Height - 1.0f, m_headerRect.X + m_headerRect.Width, + m_headerRect.Y + m_headerRect.Height - 1.0f, m_theme->border, 1.0f); + if (m_styleRect.Height > 0.0f) + { + UiDrawLine(g, m_styleRect.X, m_styleRect.Y, m_styleRect.X + m_styleRect.Width, m_styleRect.Y, + m_theme->border, 1.0f); + } + } + + void SignalPanel::PaintRow(gd::Graphics& g, int row) + { + if (m_document == nullptr || row < 0 || row >= static_cast(m_document->Signals().size())) + { + return; + } + + const Signal& signal = m_document->Signals()[static_cast(row)]; + const gd::RectF rect = RowRect(row); + const bool selected = (m_document->SelectedSignal() == row); + + if (selected) + { + UiFillRect(g, rect, m_theme->selection); + UiFillRect(g, gd::RectF(rect.X, rect.Y, 2.0f * m_scale, rect.Height), m_theme->accent); + } + else if (m_hoverRow == row) + { + UiFillRect(g, rect, m_theme->hover); + } + + const float centerY = rect.Y + rect.Height * 0.5f; + + // 复选框。 + const gd::RectF checkRect(rect.X + kPadding * m_scale, centerY - kCheckSize * m_scale * 0.5f, + kCheckSize * m_scale, kCheckSize * m_scale); + if (signal.visible) + { + UiFillRoundRect(g, checkRect, 3.0f * m_scale, m_theme->accent); + gd::Pen pen(ToGdiColor(m_theme->textOnAccent), 1.6f * m_scale); + pen.SetStartCap(gd::LineCapRound); + pen.SetEndCap(gd::LineCapRound); + g.DrawLine(&pen, checkRect.X + 3.0f * m_scale, centerY, + checkRect.X + 6.0f * m_scale, checkRect.Y + checkRect.Height - 4.0f * m_scale); + g.DrawLine(&pen, checkRect.X + 6.0f * m_scale, checkRect.Y + checkRect.Height - 4.0f * m_scale, + checkRect.X + checkRect.Width - 3.0f * m_scale, checkRect.Y + 4.0f * m_scale); + } + else + { + UiFillRoundRect(g, checkRect, 3.0f * m_scale, m_theme->cardBg); + UiDrawRoundRect(g, checkRect, 3.0f * m_scale, m_theme->borderStrong, 1.0f); + } + + // 颜色块。 + const gd::RectF swatchRect(checkRect.X + checkRect.Width + 10.0f * m_scale, + centerY - kSwatchSize * m_scale * 0.5f, kSwatchSize * m_scale, + kSwatchSize * m_scale); + UiFillRoundRect(g, swatchRect, 2.5f * m_scale, + signal.visible ? signal.color : WithAlpha(signal.color, 0x55)); + + // 名称。 + const float badgeOffset = kBadgeWidth * m_scale + 8.0f * m_scale; + const float nameLeft = swatchRect.X + swatchRect.Width + 10.0f * m_scale; + const float nameWidth = rect.X + rect.Width - kPadding * m_scale - badgeOffset - nameLeft; + const std::wstring nameText = UiEllipsize(g, signal.name, m_fonts->ui, nameWidth); + UiText(g, nameText, m_fonts->ui, gd::RectF(nameLeft, rect.Y, nameWidth, rect.Height), + signal.visible ? m_theme->text : m_theme->textMuted, TextAlign::LEFT); + + // 网格标记。 + const int laneIndex = m_document->LaneOfSignal(row); + if (laneIndex >= 0) + { + const gd::RectF badgeRect(rect.X + rect.Width - kPadding * m_scale - kBadgeWidth * m_scale, + centerY - kBadgeHeight * m_scale * 0.5f, kBadgeWidth * m_scale, + kBadgeHeight * m_scale); + const bool laneActive = (selected && m_hoverStyle == StyleHit::LANE); + UiFillRoundRect(g, badgeRect, kBadgeHeight * m_scale * 0.5f, + laneActive ? m_theme->accentSoft : m_theme->cardBg); + UiDrawRoundRect(g, badgeRect, kBadgeHeight * m_scale * 0.5f, m_theme->border, 1.0f); + UiText(g, L"G" + std::to_wstring(laneIndex + 1), m_fonts->monoSmall, badgeRect, m_theme->text, + TextAlign::CENTER); + } + } + + void SignalPanel::PaintStyleSection(gd::Graphics& g) + { + if (m_styleRect.Height <= 0.0f) + { + return; + } + + UiFillRect(g, m_styleRect, m_theme->panelBg); + + const int selectedIndex = (m_document == nullptr) ? -1 : m_document->SelectedSignal(); + if (m_document == nullptr || selectedIndex < 0 || + selectedIndex >= static_cast(m_document->Signals().size())) + { + UiText(g, L"请选择信号以编辑样式", m_fonts->uiSmall, m_styleRect, m_theme->textMuted, TextAlign::CENTER); + return; + } + + const Signal& signal = m_document->Signals()[static_cast(selectedIndex)]; + + // 标题行。 + const gd::RectF titleRect(m_styleRect.X + kPadding * m_scale, m_styleRect.Y, + m_styleRect.Width - kPadding * 2.0f * m_scale, 28.0f * m_scale); + UiText(g, L"样式 · " + signal.name, m_fonts->uiBold, titleRect, m_theme->textStrong, TextAlign::LEFT); + + const float labelX = m_styleRect.X + kPadding * m_scale; + const float labelWidth = kLineLabelWidth * m_scale; + + // 颜色。 + { + const float rowTop = m_styleRect.Y + 30.0f * m_scale; + UiText(g, L"颜色", m_fonts->uiSmall, + gd::RectF(labelX, rowTop, labelWidth, 20.0f * m_scale), m_theme->textMuted, TextAlign::LEFT); + + int paletteCount = 0; + const std::uint32_t* palette = SignalPalette(paletteCount); + for (int index = 0; index < m_colorCount; ++index) + { + const gd::RectF& rect = m_colorRects[static_cast(index)]; + const std::uint32_t color = palette[index % paletteCount]; + const bool hovered = (m_hoverStyle == StyleHit::COLOR && m_hoverStyleIndex == index); + const bool active = (signal.color == color); + + UiFillRoundRect(g, rect, 4.0f * m_scale, color); + if (active || hovered) + { + const gd::RectF ring(rect.X - 2.0f * m_scale, rect.Y - 2.0f * m_scale, + rect.Width + 4.0f * m_scale, rect.Height + 4.0f * m_scale); + UiDrawRoundRect(g, ring, 5.0f * m_scale, active ? m_theme->textStrong : m_theme->borderStrong, + active ? 1.6f : 1.0f); + } + } + } + + // 线宽。 + { + const gd::RectF& first = m_widthRects[0]; + UiText(g, L"线宽", m_fonts->uiSmall, gd::RectF(labelX, first.Y, labelWidth, first.Height), + m_theme->textMuted, TextAlign::LEFT); + + for (int index = 0; index < 4; ++index) + { + const gd::RectF& rect = m_widthRects[static_cast(index)]; + const bool active = std::fabs(signal.lineWidth - kWidthOptions[index]) < 0.01f; + const bool hovered = (m_hoverStyle == StyleHit::WIDTH && m_hoverStyleIndex == index); + + UiFillRoundRect(g, rect, kCorner * m_scale, + active ? m_theme->accentSoft : (hovered ? m_theme->hover : m_theme->cardBg)); + UiDrawRoundRect(g, rect, kCorner * m_scale, active ? m_theme->accent : m_theme->border, 1.0f); + + // 以实际线宽绘制示意线段。 + const float centerY = rect.Y + rect.Height * 0.5f; + const float lineWidth = kWidthOptions[index] * m_scale; + UiDrawLine(g, rect.X + 8.0f * m_scale, centerY, rect.X + rect.Width - 8.0f * m_scale, centerY, + active ? m_theme->accent : m_theme->text, lineWidth); + } + } + + // 绘制方式。 + { + const gd::RectF& first = m_modeRects[0]; + UiText(g, L"方式", m_fonts->uiSmall, gd::RectF(labelX, first.Y, labelWidth, first.Height), + m_theme->textMuted, TextAlign::LEFT); + + for (int index = 0; index < 4; ++index) + { + const gd::RectF& rect = m_modeRects[static_cast(index)]; + const bool active = (signal.mode == kModeOptions[index]); + const bool hovered = (m_hoverStyle == StyleHit::MODE && m_hoverStyleIndex == index); + + UiFillRoundRect(g, rect, kCorner * m_scale, + active ? m_theme->accentSoft : (hovered ? m_theme->hover : m_theme->cardBg)); + UiDrawRoundRect(g, rect, kCorner * m_scale, active ? m_theme->accent : m_theme->border, 1.0f); + UiText(g, DrawModeName(kModeOptions[index]), m_fonts->uiSmall, rect, + active ? m_theme->textStrong : m_theme->text, TextAlign::CENTER); + } + } + + // 所属网格。 + { + UiText(g, L"网格", m_fonts->uiSmall, + gd::RectF(labelX, m_laneButtonRect.Y, labelWidth, m_laneButtonRect.Height), m_theme->textMuted, + TextAlign::LEFT); + + const bool hovered = (m_hoverStyle == StyleHit::LANE); + UiFillRoundRect(g, m_laneButtonRect, kCorner * m_scale, hovered ? m_theme->hover : m_theme->cardBg); + UiDrawRoundRect(g, m_laneButtonRect, kCorner * m_scale, m_theme->border, 1.0f); + + const int laneIndex = m_document->LaneOfSignal(selectedIndex); + std::wstring label = (laneIndex >= 0) ? (L"网格 " + std::to_wstring(laneIndex + 1)) : L"未分配"; + + const gd::RectF textRect(m_laneButtonRect.X + 8.0f * m_scale, m_laneButtonRect.Y, + m_laneButtonRect.Width - 26.0f * m_scale, m_laneButtonRect.Height); + UiText(g, label, m_fonts->uiSmall, textRect, m_theme->text, TextAlign::LEFT); + + // 下拉箭头。 + const float arrowX = m_laneButtonRect.X + m_laneButtonRect.Width - 16.0f * m_scale; + const float arrowY = m_laneButtonRect.Y + m_laneButtonRect.Height * 0.5f; + gd::PointF points[3]; + points[0] = gd::PointF(arrowX - 4.0f * m_scale, arrowY - 2.0f * m_scale); + points[1] = gd::PointF(arrowX + 4.0f * m_scale, arrowY - 2.0f * m_scale); + points[2] = gd::PointF(arrowX, arrowY + 3.0f * m_scale); + gd::SolidBrush brush(ToGdiColor(m_theme->textMuted)); + g.FillPolygon(&brush, points, 3); + } + } + + bool SignalPanel::AreAllSignalsVisible() const + { + if (m_document == nullptr || m_document->Signals().empty()) + { + return false; + } + for (const Signal& signal : m_document->Signals()) + { + if (!signal.visible) + { + return false; + } + } + return true; + } + + bool SignalPanel::OnMouseMove(int x, int y) + { + const int hoverRow = RowAtY(y); + StyleHit hoverStyle = StyleHit::NONE; + int hoverStyleIndex = -1; + + const float fx = static_cast(x); + const float fy = static_cast(y); + + if (UiPointInRect(m_toggleAllRect, fx, fy)) + { + hoverStyle = StyleHit::TOGGLE_ALL; + } + else if (m_document != nullptr && m_document->SelectedSignal() >= 0) + { + for (int index = 0; index < m_colorCount; ++index) + { + if (UiPointInRect(m_colorRects[static_cast(index)], fx, fy)) + { + hoverStyle = StyleHit::COLOR; + hoverStyleIndex = index; + break; + } + } + if (hoverStyle == StyleHit::NONE) + { + for (int index = 0; index < 4; ++index) + { + if (UiPointInRect(m_widthRects[static_cast(index)], fx, fy)) + { + hoverStyle = StyleHit::WIDTH; + hoverStyleIndex = index; + break; + } + } + } + if (hoverStyle == StyleHit::NONE) + { + for (int index = 0; index < 4; ++index) + { + if (UiPointInRect(m_modeRects[static_cast(index)], fx, fy)) + { + hoverStyle = StyleHit::MODE; + hoverStyleIndex = index; + break; + } + } + } + if (hoverStyle == StyleHit::NONE && UiPointInRect(m_laneButtonRect, fx, fy)) + { + hoverStyle = StyleHit::LANE; + } + } + + if (hoverRow == m_hoverRow && hoverStyle == m_hoverStyle && hoverStyleIndex == m_hoverStyleIndex) + { + return false; + } + + m_hoverRow = hoverRow; + m_hoverStyle = hoverStyle; + m_hoverStyleIndex = hoverStyleIndex; + return true; + } + + bool SignalPanel::OnLButtonDown(int x, int y) + { + if (m_document == nullptr) + { + return false; + } + + const float fx = static_cast(x); + const float fy = static_cast(y); + + // 全部显示/隐藏。 + if (UiPointInRect(m_toggleAllRect, fx, fy)) + { + m_document->SetAllVisible(!AreAllSignalsVisible()); + return true; + } + + // 样式编辑区。 + const int selectedIndex = m_document->SelectedSignal(); + if (selectedIndex >= 0 && selectedIndex < static_cast(m_document->Signals().size())) + { + Signal& signal = m_document->Signals()[static_cast(selectedIndex)]; + + int paletteCount = 0; + const std::uint32_t* palette = SignalPalette(paletteCount); + for (int index = 0; index < m_colorCount; ++index) + { + if (UiPointInRect(m_colorRects[static_cast(index)], fx, fy)) + { + signal.color = palette[index % paletteCount]; + return true; + } + } + for (int index = 0; index < 4; ++index) + { + if (UiPointInRect(m_widthRects[static_cast(index)], fx, fy)) + { + signal.lineWidth = kWidthOptions[index]; + return true; + } + } + for (int index = 0; index < 4; ++index) + { + if (UiPointInRect(m_modeRects[static_cast(index)], fx, fy)) + { + signal.mode = kModeOptions[index]; + return true; + } + } + if (UiPointInRect(m_laneButtonRect, fx, fy)) + { + ShowLaneMenu(); + return true; + } + } + + // 列表行。 + const int row = RowAtY(y); + if (row >= 0) + { + const gd::RectF rect = RowRect(row); + const gd::RectF checkRect(rect.X + kPadding * m_scale, rect.Y + (rect.Height - kCheckSize * m_scale) * 0.5f, + kCheckSize * m_scale, kCheckSize * m_scale); + + if (UiPointInRect(checkRect, fx, fy)) + { + Signal& signal = m_document->Signals()[static_cast(row)]; + signal.visible = !signal.visible; + m_document->SetSelectedSignal(row); + return true; + } + + m_document->SetSelectedSignal(row); + return true; + } + return false; + } + + bool SignalPanel::OnLButtonUp(int /*x*/, int /*y*/) + { + return false; + } + + bool SignalPanel::OnDoubleClick(int x, int y) + { + if (m_document == nullptr) + { + return false; + } + + const int row = RowAtY(y); + if (row < 0) + { + return false; + } + + Signal& signal = m_document->Signals()[static_cast(row)]; + signal.visible = !signal.visible; + m_document->SetSelectedSignal(row); + return true; + } + + bool SignalPanel::OnRButtonDown(int x, int y) + { + if (m_document == nullptr) + { + return false; + } + + const int row = RowAtY(y); + if (row < 0) + { + return false; + } + + m_document->SetSelectedSignal(row); + + POINT cursor = {x, y}; + if (m_host != nullptr) + { + ClientToScreen(m_host, &cursor); + } + ShowSignalMenu(row, cursor.x, cursor.y); + return true; + } + + bool SignalPanel::OnMouseWheel(int delta) + { + if (m_document == nullptr) + { + return false; + } + + const float previous = m_scrollOffset; + m_scrollOffset -= static_cast(delta) / 120.0f * 54.0f * m_scale; + ClampScroll(); + return std::fabs(previous - m_scrollOffset) > 0.01f; + } + + bool SignalPanel::OnMouseLeave() + { + if (m_hoverRow < 0 && m_hoverStyle == StyleHit::NONE) + { + return false; + } + m_hoverRow = -1; + m_hoverStyle = StyleHit::NONE; + m_hoverStyleIndex = -1; + return true; + } + + void SignalPanel::ShowLaneMenu() + { + if (m_document == nullptr || m_host == nullptr) + { + return; + } + + const int selectedIndex = m_document->SelectedSignal(); + if (selectedIndex < 0) + { + return; + } + + HMENU menu = CreatePopupMenu(); + if (menu == nullptr) + { + return; + } + + const int currentLane = m_document->LaneOfSignal(selectedIndex); + const std::vector& lanes = m_document->Lanes(); + for (std::size_t index = 0; index < lanes.size(); ++index) + { + std::wstring label = L"网格 " + std::to_wstring(index + 1) + L"(" + + std::to_wstring(lanes[index].signalIndices.size()) + L" 条信号)"; + AppendMenuW(menu, MF_STRING, static_cast(kMenuLaneBase + index), label.c_str()); + } + AppendMenuW(menu, MF_SEPARATOR, 0, nullptr); + AppendMenuW(menu, MF_STRING, static_cast(kMenuLaneNew), L"新建网格"); + + if (currentLane >= 0 && currentLane < static_cast(lanes.size())) + { + CheckMenuRadioItem(menu, static_cast(kMenuLaneBase), + static_cast(kMenuLaneBase + lanes.size() - 1), + static_cast(kMenuLaneBase + currentLane), MF_BYCOMMAND); + } + + POINT anchor = {static_cast(m_laneButtonRect.X), static_cast(m_laneButtonRect.Y)}; + ClientToScreen(m_host, &anchor); + + SetForegroundWindow(m_host); + const int command = TrackPopupMenu(menu, TPM_RETURNCMD | TPM_LEFTALIGN | TPM_TOPALIGN, anchor.x, + anchor.y + static_cast(m_laneButtonRect.Height), 0, m_host, nullptr); + DestroyMenu(menu); + + if (command >= kMenuLaneBase && command < kMenuLaneNew) + { + m_document->MoveSignalToLane(selectedIndex, command - kMenuLaneBase); + } + else if (command == kMenuLaneNew) + { + m_document->MoveSignalToLane(selectedIndex, -1); + } + } + + void SignalPanel::ShowSignalMenu(int signalIndex, int screenX, int screenY) + { + if (m_document == nullptr || m_host == nullptr) + { + return; + } + if (signalIndex < 0 || signalIndex >= static_cast(m_document->Signals().size())) + { + return; + } + + Signal& signal = m_document->Signals()[static_cast(signalIndex)]; + + HMENU menu = CreatePopupMenu(); + if (menu == nullptr) + { + return; + } + + AppendMenuW(menu, MF_STRING, static_cast(kMenuToggleVisible), + signal.visible ? L"隐藏该信号" : L"显示该信号"); + AppendMenuW(menu, MF_STRING, static_cast(kMenuSolo), L"仅显示该信号"); + AppendMenuW(menu, MF_STRING, static_cast(kMenuShowAll), L"显示全部信号"); + AppendMenuW(menu, MF_SEPARATOR, 0, nullptr); + + HMENU colorMenu = CreatePopupMenu(); + int paletteCount = 0; + const std::uint32_t* palette = SignalPalette(paletteCount); + const wchar_t* colorNames[] = {L"蓝", L"绿", L"红", L"紫", L"黄", L"青", L"品红", L"橙", L"深灰", L"灰"}; + for (int index = 0; index < SignalColorCount(); ++index) + { + const std::uint32_t color = palette[index % paletteCount]; + std::wstring label = (index < 10) ? std::wstring(colorNames[index]) : (L"颜色 " + std::to_wstring(index + 1)); + AppendMenuW(colorMenu, MF_STRING, static_cast(kMenuColorBase + index), label.c_str()); + if (signal.color == color) + { + CheckMenuRadioItem(colorMenu, static_cast(kMenuColorBase), + static_cast(kMenuColorBase + SignalColorCount() - 1), + static_cast(kMenuColorBase + index), MF_BYCOMMAND); + } + } + AppendMenuW(menu, MF_POPUP, reinterpret_cast(colorMenu), L"颜色"); + + HMENU widthMenu = CreatePopupMenu(); + for (int index = 0; index < 4; ++index) + { + const std::wstring label = FormatNumber(kWidthOptions[index], 1) + L" px"; + AppendMenuW(widthMenu, MF_STRING, static_cast(kMenuWidthBase + index), label.c_str()); + if (std::fabs(signal.lineWidth - kWidthOptions[index]) < 0.01f) + { + CheckMenuRadioItem(widthMenu, static_cast(kMenuWidthBase), + static_cast(kMenuWidthBase + 3), + static_cast(kMenuWidthBase + index), MF_BYCOMMAND); + } + } + AppendMenuW(menu, MF_POPUP, reinterpret_cast(widthMenu), L"线宽"); + + HMENU modeMenu = CreatePopupMenu(); + for (int index = 0; index < 4; ++index) + { + AppendMenuW(modeMenu, MF_STRING, static_cast(kMenuModeBase + index), + DrawModeName(kModeOptions[index])); + if (signal.mode == kModeOptions[index]) + { + CheckMenuRadioItem(modeMenu, static_cast(kMenuModeBase), static_cast(kMenuModeBase + 3), + static_cast(kMenuModeBase + index), MF_BYCOMMAND); + } + } + AppendMenuW(menu, MF_POPUP, reinterpret_cast(modeMenu), L"显示方式"); + + HMENU laneMenu = CreatePopupMenu(); + const std::vector& lanes = m_document->Lanes(); + const int currentLane = m_document->LaneOfSignal(signalIndex); + for (std::size_t index = 0; index < lanes.size(); ++index) + { + const std::wstring laneLabel = L"网格 " + std::to_wstring(index + 1); + AppendMenuW(laneMenu, MF_STRING, static_cast(kMenuLaneBase + index), laneLabel.c_str()); + } + AppendMenuW(laneMenu, MF_SEPARATOR, 0, nullptr); + AppendMenuW(laneMenu, MF_STRING, static_cast(kMenuLaneNew), L"新建网格"); + if (currentLane >= 0 && !lanes.empty()) + { + CheckMenuRadioItem(laneMenu, static_cast(kMenuLaneBase), + static_cast(kMenuLaneBase + lanes.size() - 1), + static_cast(kMenuLaneBase + currentLane), MF_BYCOMMAND); + } + AppendMenuW(menu, MF_POPUP, reinterpret_cast(laneMenu), L"所属网格"); + + SetForegroundWindow(m_host); + const int command = TrackPopupMenu(menu, TPM_RETURNCMD | TPM_LEFTALIGN | TPM_TOPALIGN, screenX, screenY, 0, + m_host, nullptr); + DestroyMenu(menu); + + if (command == kMenuToggleVisible) + { + signal.visible = !signal.visible; + } + else if (command == kMenuSolo) + { + m_document->SoloSignal(signalIndex); + } + else if (command == kMenuShowAll) + { + m_document->SetAllVisible(true); + } + else if (command >= kMenuModeBase && command < kMenuModeBase + 4) + { + signal.mode = kModeOptions[command - kMenuModeBase]; + } + else if (command >= kMenuWidthBase && command < kMenuWidthBase + 4) + { + signal.lineWidth = kWidthOptions[command - kMenuWidthBase]; + } + else if (command >= kMenuColorBase && command < kMenuColorBase + SignalColorCount()) + { + signal.color = palette[(command - kMenuColorBase) % paletteCount]; + } + else if (command >= kMenuLaneBase && command < kMenuLaneNew) + { + m_document->MoveSignalToLane(signalIndex, command - kMenuLaneBase); + } + else if (command == kMenuLaneNew) + { + m_document->MoveSignalToLane(signalIndex, -1); + } + } + +} // namespace jdi diff --git a/Components/Toolbar/Inc/Toolbar.hpp b/Components/Toolbar/Inc/Toolbar.hpp new file mode 100644 index 0000000..b1aab4c --- /dev/null +++ b/Components/Toolbar/Inc/Toolbar.hpp @@ -0,0 +1,160 @@ +#ifndef __TOOLBAR_HPP__ +#define __TOOLBAR_HPP__ + +#include +#include +#include + +#include +#include + +#include "PaintUtil.hpp" +#include "Theme.hpp" + +namespace jdi +{ + + /** + * @brief 工具栏按钮触发的动作。 + */ + enum class ToolbarAction : int + { + NONE = 0, /**< 未触发任何动作。 */ + OPEN_FILE, /**< 打开 CSV 文件。 */ + FIT_ALL, /**< 时间与幅值同时自适应。 */ + FIT_TIME, /**< 仅时间轴自适应。 */ + ZOOM_IN, /**< 放大时间轴。 */ + ZOOM_OUT, /**< 缩小时间轴。 */ + MERGE_LANES, /**< 合并为单一网格。 */ + SPLIT_LANES, /**< 按信号拆分为多网格。 */ + RESET_VIEW /**< 恢复默认视图范围。 */ + }; + + /** + * @brief 顶部工具栏:文件操作与视图控制的入口。 + */ + class Toolbar + { + public: + Toolbar(); + + /** + * @brief 绑定字体与主题资源。 + * @param [IN] fonts 字体集合,需在工具栏生命周期内保持有效。 + * @param [IN] theme 主题配色,需在工具栏生命周期内保持有效。 + */ + void Setup(const UiFonts* fonts, const Theme* theme); + + /** + * @brief 设置 DPI 缩放比例。 + * @param [IN] scale 缩放比例(1.0 对应 96 DPI)。 + */ + void SetScale(float scale); + + /** + * @brief 设置是否已加载数据(未加载时大部分按钮被禁用)。 + * @param [IN] hasDocument 是否已加载数据。 + */ + void SetDocumentLoaded(bool hasDocument); + + /** + * @brief 设置工具栏右侧的状态文本。 + * @param [IN] text 状态文本。 + */ + void SetStatusText(const std::wstring& text); + + /** + * @brief 计算工具栏在指定缩放下的期望高度。 + * @return 期望高度(像素)。 + */ + float DesiredHeight() const; + + /** + * @brief 按给定区域重新排布按钮。 + * @param [IN] area 工具栏可用区域。 + */ + void Layout(const gd::RectF& area); + + /** + * @brief 绘制工具栏。 + * @param [IN,OUT] g 绘图上下文。 + */ + void Paint(gd::Graphics& g); + + /** + * @brief 处理鼠标移动,更新悬停状态。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @return 悬停状态发生变化返回 true。 + */ + bool OnMouseMove(int x, int y); + + /** + * @brief 处理鼠标左键按下。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @param [OUT] action 命中的按钮动作。 + * @return 命中按钮返回 true。 + */ + bool OnLButtonDown(int x, int y, ToolbarAction& action); + + /** + * @brief 处理鼠标左键抬起。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @param [OUT] action 抬起时命中的按钮动作(仅在按钮上抬起时有效)。 + * @return 视觉状态发生变化返回 true。 + */ + bool OnLButtonUp(int x, int y, ToolbarAction& action); + + /** + * @brief 处理鼠标离开工具栏。 + * @return 视觉状态发生变化返回 true。 + */ + bool OnMouseLeave(); + + /** + * @brief 判断坐标是否落在工具栏区域内。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @return 落在区域内返回 true。 + */ + bool HitTest(int x, int y) const; + + private: + /** + * @brief 单个工具栏按钮的运行时状态。 + */ + struct Button + { + ToolbarAction action = ToolbarAction::NONE; /**< 按钮动作。 */ + std::wstring label; /**< 按钮文本。 */ + gd::RectF rect; /**< 按钮矩形。 */ + float width = 0.0f; /**< 按钮宽度。 */ + bool primary = false; /**< 是否为主按钮(强调色填充)。 */ + bool needsDocument = true; /**< 是否依赖已加载数据。 */ + bool hover = false; /**< 悬停状态。 */ + bool pressed = false; /**< 按下状态。 */ + }; + + /** + * @brief 判断按钮当前是否可用。 + * @param [IN] button 目标按钮。 + * @return 可用返回 true。 + */ + bool IsEnabled(const Button& button) const; + + const UiFonts* m_fonts = nullptr; /**< 字体资源。 */ + const Theme* m_theme = nullptr; /**< 主题资源。 */ + float m_scale = 1.0f; /**< DPI 缩放比例。 */ + bool m_documentLoaded = false; /**< 是否已加载数据。 */ + std::wstring m_statusText; /**< 右侧状态文本。 */ + std::vector