diff --git a/Components/Application/Inc/Application.hpp b/Components/Application/Inc/Application.hpp index a454630..bd73aff 100644 --- a/Components/Application/Inc/Application.hpp +++ b/Components/Application/Inc/Application.hpp @@ -6,11 +6,13 @@ #include #include +#include +#include "DataModel.hpp" #include "PaintUtil.hpp" #include "PlotView.hpp" #include "SignalPanel.hpp" -#include "SignalSet.hpp" +#include "TabBar.hpp" #include "Theme.hpp" #include "Toolbar.hpp" @@ -18,7 +20,10 @@ namespace jdi { /** - * @brief 应用主框架:负责窗口、布局、消息分发、绘制合成与命令处理。 + * @brief 应用主框架:负责窗口、布局、消息分发、标签页管理与绘制合成。 + * + * 数据层(DataSet)全局共享,标签页仅保存各自的视图配置(TabConfig), + * 因此新标签页可以独立勾选信号、独立配色与分组,互不影响。 */ class Application { @@ -54,6 +59,7 @@ namespace jdi enum class Target : int { NONE = 0, /**< 无目标。 */ + TAB_BAR, /**< 标签栏。 */ TOOLBAR, /**< 工具栏。 */ PANEL, /**< 信号面板。 */ PLOT, /**< 波形绘图区。 */ @@ -122,6 +128,69 @@ namespace jdi */ void PaintStatusBar(gd::Graphics& g); + /** + * @brief 获取当前激活标签页配置(可写)。 + * @return 激活标签页配置引用。 + */ + TabConfig& ActiveTab(); + + /** + * @brief 获取当前激活标签页配置(只读)。 + * @return 激活标签页配置引用。 + */ + const TabConfig& ActiveTab() const; + + /** + * @brief 新建标签页并切换为激活页。 + */ + void NewTab(); + + /** + * @brief 关闭指定标签页。 + * @param [IN] index 标签页下标。 + */ + void CloseTab(int index); + + /** + * @brief 切换激活标签页并重新绑定各组件上下文。 + * @param [IN] index 标签页下标。 + */ + void SetActiveTab(int index); + + /** + * @brief 将标签栏内容与当前标签页集合同步。 + */ + void RefreshTabBar(); + + /** + * @brief 检测激活标签页的可见性/网格变化,并同步绘图区与布局。 + * @param [IN] force 是否强制同步(忽略缓存比对)。 + */ + void SynchronizeView(bool force); + + /** + * @brief 将全部标签页配置与数据层对齐(文件增删后调用)。 + */ + void SyncTabsToDataSet(); + + /** + * @brief 从工作区移除指定文件并修正各标签页配置。 + * @param [IN] fileIndex 文件下标。 + */ + void RemoveFile(int fileIndex); + + /** + * @brief 处理标签栏交互结果。 + * @param [IN] result 交互结果。 + */ + void OnTabBarResult(const TabBarResult& result); + + /** + * @brief 弹出标签页右键菜单。 + * @param [IN] result 上下文菜单请求。 + */ + void ShowTabContextMenu(const TabBarResult& result); + /** * @brief 弹出文件选择对话框并加载所选文件。 */ @@ -162,7 +231,7 @@ namespace jdi /** * @brief 解析命令行中的文件参数并加载。 */ - void LoadCommandLineFile(); + void LoadCommandLineFiles(); /** * @brief 处理客户区鼠标移动。 @@ -182,18 +251,24 @@ namespace jdi Theme m_theme; /**< 主题配色。 */ UiFonts m_fonts; /**< 界面字体。 */ - SignalSet m_document; /**< 已加载的数据集。 */ + DataSet m_dataSet; /**< 全局共享的数据集合。 */ + std::vector m_tabs; /**< 标签页配置集合。 */ + int m_activeTab = -1; /**< 当前激活标签页下标。 */ + int m_tabNameCounter = 0; /**< 标签页命名计数器。 */ + + TabBar m_tabBar; /**< 顶部标签栏。 */ Toolbar m_toolbar; /**< 顶部工具栏。 */ SignalPanel m_panel; /**< 左侧信号面板。 */ PlotView m_plot; /**< 波形绘图区。 */ + gd::RectF m_tabBarRect; /**< 标签栏区域。 */ 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; /**< 面板宽度(逻辑像素)。 */ + float m_panelWidth = 300.0f; /**< 面板宽度(逻辑像素)。 */ bool m_draggingSplitter = false; /**< 是否正在拖动分隔条。 */ int m_splitterGrabOffset = 0; /**< 分隔条拖动的抓取偏移。 */ @@ -205,9 +280,12 @@ namespace jdi Target m_activeTarget = Target::NONE; /**< 当前鼠标作用目标。 */ bool m_trackingLeave = false; /**< 是否已注册鼠标离开通知。 */ - std::size_t m_lastLaneCount = 0; /**< 上次渲染时的网格数量,用于检测布局失效。 */ std::wstring m_statusMessage; /**< 状态栏提示文本。 */ bool m_statusIsError = false; /**< 状态提示是否为错误。 */ + + int m_cachedVisibleCount = -1; /**< 上次同步时的可见信号数量。 */ + std::size_t m_cachedLaneCount = 0; /**< 上次同步时的网格数量。 */ + bool m_cachedAnyVisible = false; /**< 上次同步时是否存在可见信号。 */ }; } // namespace jdi diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index 63ecbcb..1b1fa43 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -27,12 +27,18 @@ namespace jdi 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 float kMinPanelWidth = 220.0f; /**< 面板最小宽度(逻辑像素)。 */ + const float kMaxPanelWidth = 480.0f; /**< 面板最大宽度(逻辑像素)。 */ + const int kMinClientWidth = 860; /**< 窗口最小客户区宽度。 */ + const int kMinClientHeight = 540; /**< 窗口最小客户区高度。 */ + const UINT kMessageRemoveFile = WM_APP + 1; /**< 面板请求移除文件的自定义消息。 */ const wchar_t* kStatusHint = L"滚轮缩放 · Shift+滚轮平移 · Ctrl+滚轮调幅值 · 右键框选 · 双击自适应"; + /** @brief 标签页右键菜单命令 ID。 */ + const int kMenuTabNew = 1; + const int kMenuTabClose = 2; + const int kMenuTabCloseOthers = 3; + /** * @brief 开启进程级 DPI 感知(优先 Per-Monitor V2)。 */ @@ -45,8 +51,7 @@ namespace jdi SetProcessDpiAwarenessContextFn setContext = reinterpret_cast( reinterpret_cast(GetProcAddress(user32, "SetProcessDpiAwarenessContext"))); - if (setContext != nullptr && - setContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) != FALSE) + if (setContext != nullptr && setContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) != FALSE) { return; } @@ -166,14 +171,14 @@ namespace jdi } // 依据工作区(排除任务栏)计算默认窗口尺寸并居中,避免窗口超出屏幕。 - RECT workArea = {0, 0, static_cast(ScaleOf(1340.0f, m_scale)), - static_cast(ScaleOf(860.0f, m_scale))}; + RECT workArea = {0, 0, static_cast(ScaleOf(1380.0f, m_scale)), + static_cast(ScaleOf(880.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 windowWidth = std::min(static_cast(ScaleOf(1380.0f, m_scale)), workWidth - 24); + const int windowHeight = std::min(static_cast(ScaleOf(880.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); @@ -186,6 +191,8 @@ namespace jdi return false; } + m_tabBar.Setup(&m_fonts, &m_theme); + m_tabBar.SetScale(m_scale); m_toolbar.Setup(&m_fonts, &m_theme); m_toolbar.SetScale(m_scale); m_panel.Setup(m_hwnd, &m_fonts, &m_theme); @@ -197,10 +204,13 @@ namespace jdi SendMessageW(m_hwnd, WM_SETICON, ICON_BIG, reinterpret_cast(icon)); SendMessageW(m_hwnd, WM_SETICON, ICON_SMALL, reinterpret_cast(icon)); + // 创建首个标签页。 + NewTab(); + Relayout(); ShowWindow(m_hwnd, showCommand); UpdateWindow(m_hwnd); - LoadCommandLineFile(); + LoadCommandLineFiles(); return true; } @@ -227,6 +237,7 @@ namespace jdi m_scale = static_cast(dpi) / 96.0f; m_fonts.Create(dpi); + m_tabBar.SetScale(m_scale); m_toolbar.SetScale(m_scale); m_panel.SetScale(m_scale); m_plot.SetScale(m_scale); @@ -234,6 +245,179 @@ namespace jdi Relayout(); } + TabConfig& Application::ActiveTab() + { + static TabConfig fallback; + if (m_activeTab < 0 || m_activeTab >= static_cast(m_tabs.size())) + { + return fallback; + } + return m_tabs[static_cast(m_activeTab)]; + } + + const TabConfig& Application::ActiveTab() const + { + static const TabConfig fallback; + if (m_activeTab < 0 || m_activeTab >= static_cast(m_tabs.size())) + { + return fallback; + } + return m_tabs[static_cast(m_activeTab)]; + } + + void Application::NewTab() + { + ++m_tabNameCounter; + + TabConfig tab; + tab.SetName(L"视图 " + std::to_wstring(m_tabNameCounter)); + tab.SyncToDataSet(m_dataSet); + + m_tabs.push_back(std::move(tab)); + SetActiveTab(static_cast(m_tabs.size()) - 1); + } + + void Application::CloseTab(int index) + { + if (index < 0 || index >= static_cast(m_tabs.size())) + { + return; + } + if (m_tabs.size() <= 1) + { + SetStatusMessage(L"至少需要保留一个标签页。", true); + return; + } + + m_tabs.erase(m_tabs.begin() + index); + + int next = index; + if (next >= static_cast(m_tabs.size())) + { + next = static_cast(m_tabs.size()) - 1; + } + SetActiveTab(next); + } + + void Application::SetActiveTab(int index) + { + if (index < 0 || index >= static_cast(m_tabs.size())) + { + return; + } + + m_activeTab = index; + + // 重新绑定上下文:面板与绘图区均指向同一数据层与当前标签页配置。 + m_panel.SetContext(&m_dataSet, &m_tabs[static_cast(m_activeTab)]); + m_plot.SetContext(&m_dataSet, &m_tabs[static_cast(m_activeTab)]); + + RefreshTabBar(); + Relayout(); + + // 切换标签页后把该页已选中的信号滚动到可见位置。 + m_panel.ScrollToSignal(m_tabs[static_cast(m_activeTab)].Selected()); + + SynchronizeView(true); + UpdateWindowTitle(); + InvalidateRect(m_hwnd, nullptr, TRUE); + } + + void Application::RefreshTabBar() + { + std::vector names; + names.reserve(m_tabs.size()); + for (const TabConfig& tab : m_tabs) + { + names.push_back(tab.Name()); + } + m_tabBar.SetTabs(names); + m_tabBar.SetActiveIndex(m_activeTab); + } + + void Application::SynchronizeView(bool force) + { + if (m_tabs.empty() || m_activeTab < 0) + { + return; + } + + TabConfig& tab = m_tabs[static_cast(m_activeTab)]; + + const int visibleCount = tab.VisibleCount(); + const bool anyVisible = tab.AnyVisible(); + const std::size_t laneCount = tab.Lanes().size(); + + const bool changed = (visibleCount != m_cachedVisibleCount) || (anyVisible != m_cachedAnyVisible) || + (laneCount != m_cachedLaneCount); + if (!changed && !force) + { + return; + } + + const std::size_t previousLaneCount = m_cachedLaneCount; + m_cachedVisibleCount = visibleCount; + m_cachedAnyVisible = anyVisible; + m_cachedLaneCount = laneCount; + + // 标签页首次出现可见信号时,把时间轴对齐到可见数据的范围。 + if (anyVisible && !tab.ViewInitialized()) + { + m_plot.FitAll(); + } + + const bool laneCountChanged = (laneCount != previousLaneCount); + if (laneCountChanged || force) + { + // 网格数量变化会影响滚动条与绘图区宽度,必须重新布局。 + Relayout(); + } + else + { + m_plot.RefreshLanes(); + } + } + + void Application::SyncTabsToDataSet() + { + for (TabConfig& tab : m_tabs) + { + tab.SyncToDataSet(m_dataSet); + } + m_panel.InvalidateRows(); + + // 强制下一次同步重新计算视图状态。 + m_cachedVisibleCount = -1; + m_cachedAnyVisible = false; + m_cachedLaneCount = 0; + + if (m_hwnd != nullptr) + { + InvalidateRect(m_hwnd, nullptr, TRUE); + } + } + + void Application::RemoveFile(int fileIndex) + { + const DataFile* file = m_dataSet.FileAt(fileIndex); + if (file == nullptr) + { + return; + } + const std::wstring name = file->fileName; + + // 先按旧下标修正各标签页配置,再从数据层移除,最后统一对齐。 + for (TabConfig& tab : m_tabs) + { + tab.RemapAfterFileRemoval(fileIndex); + } + m_dataSet.RemoveFile(fileIndex); + SyncTabsToDataSet(); + + SetStatusMessage(L"已从工作区移除:" + name, false); + UpdateWindowTitle(); + } + void Application::Relayout() { RECT client = {}; @@ -245,25 +429,29 @@ namespace jdi return; } + const float tabBarHeight = m_tabBar.DesiredHeight(); 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_tabBarRect = gd::RectF(0.0f, 0.0f, width, tabBarHeight); + m_toolbarRect = gd::RectF(0.0f, m_tabBarRect.Y + m_tabBarRect.Height, width, toolbarHeight); m_statusRect = gd::RectF(0.0f, height - statusHeight, width, statusHeight); - const float contentHeight = std::max(0.0f, height - toolbarHeight - statusHeight); + const float contentTop = m_toolbarRect.Y + m_toolbarRect.Height; + const float contentHeight = std::max(0.0f, m_statusRect.Y - contentTop); 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, + m_panelRect = gd::RectF(0.0f, contentTop, panelWidth, contentHeight); + m_splitterRect = gd::RectF(panelWidth, contentTop, splitterWidth, contentHeight); + m_plotRect = gd::RectF(panelWidth + splitterWidth, contentTop, std::max(0.0f, width - panelWidth - splitterWidth), contentHeight); + m_tabBar.Layout(m_tabBarRect); m_toolbar.Layout(m_toolbarRect); m_panel.Layout(m_panelRect); m_plot.Layout(m_plotRect); @@ -340,16 +528,11 @@ namespace jdi return; } - // 网格数量变化(如合并/拆分/移动信号)会影响滚动条与绘图区宽度,需要重新布局。 - const std::size_t laneCount = m_document.IsLoaded() ? m_document.Lanes().size() : 0; - if (laneCount != m_lastLaneCount) - { - m_lastLaneCount = laneCount; - Relayout(); - } + // 任何交互(勾选、移动网格、切换标签页)之后都在此统一收敛视图状态。 + SynchronizeView(false); - m_toolbar.SetDocumentLoaded(m_document.IsLoaded()); - if (m_document.IsLoaded()) + m_toolbar.SetDocumentLoaded(!m_dataSet.Empty()); + if (!m_dataSet.Empty()) { m_toolbar.SetStatusText(L"视图 " + FormatValue(m_plot.ViewMinTime()) + L" ~ " + FormatValue(m_plot.ViewMaxTime())); @@ -368,6 +551,7 @@ namespace jdi UiFillRect(g, gd::RectF(0.0f, 0.0f, static_cast(width), static_cast(height)), m_theme.windowBg); + m_tabBar.Paint(g); m_toolbar.Paint(g); m_panel.Paint(g); m_plot.Paint(g); @@ -395,19 +579,20 @@ namespace jdi const float halfWidth = m_statusRect.Width * 0.5f; std::wstring leftText; - if (m_document.IsLoaded()) + if (!m_dataSet.Empty()) { - 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" 网格"; + const TabConfig& tab = ActiveTab(); + leftText = std::to_wstring(m_dataSet.FileCount()) + L" 个文件 · " + + FormatCount(static_cast(m_dataSet.SignalCount())) + L" 条信号 · " + + std::to_wstring(tab.VisibleCount()) + L" 条已选 · " + + std::to_wstring(tab.Lanes().size()) + L" 个网格"; } else { leftText = L"未加载数据 · 请点击「打开 CSV」或拖拽文件到窗口"; } - UiText(g, leftText, m_fonts.uiSmall, + UiText(g, UiEllipsize(g, leftText, m_fonts.uiSmall, halfWidth - padding * 2.0f), m_fonts.uiSmall, gd::RectF(m_statusRect.X + padding, m_statusRect.Y, halfWidth - padding, m_statusRect.Height), m_theme.text, TextAlign::LEFT); @@ -418,13 +603,13 @@ namespace jdi rightText = m_statusMessage; rightColor = m_statusIsError ? m_theme.danger : m_theme.textStrong; } - else if (m_plot.HasCursor() && m_document.IsLoaded()) + else if (m_plot.HasCursor() && !m_dataSet.Empty()) { rightText = L"光标 · " + FormatValue(m_plot.CursorTime()); rightColor = m_theme.text; } - UiText(g, rightText, m_fonts.uiSmall, + UiText(g, UiEllipsize(g, rightText, m_fonts.uiSmall, halfWidth - padding * 2.0f), m_fonts.uiSmall, gd::RectF(m_statusRect.X + halfWidth, m_statusRect.Y, halfWidth - padding, m_statusRect.Height), rightColor, TextAlign::RIGHT); } @@ -438,75 +623,123 @@ namespace jdi void Application::UpdateWindowTitle() { std::wstring title = kWindowTitle; - if (m_document.IsLoaded()) + if (m_activeTab >= 0 && m_activeTab < static_cast(m_tabs.size())) { title += L" — "; - title += m_document.FileName(); + title += m_tabs[static_cast(m_activeTab)].Name(); + } + if (!m_dataSet.Empty()) + { + title += L"(" + std::to_wstring(m_dataSet.FileCount()) + L" 个文件)"; } SetWindowTextW(m_hwnd, title.c_str()); } void Application::OpenFileDialog() { - wchar_t buffer[MAX_PATH * 4] = {}; + // 允许一次选择多个文件,便于跨文件对比信号。 + std::vector buffer(static_cast(MAX_PATH) * 16, L'\0'); 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.lpstrFile = buffer.data(); + dialog.nMaxFile = static_cast(buffer.size()); + dialog.lpstrTitle = L"打开仿真数据文件(可多选)"; dialog.lpstrDefExt = L"csv"; - dialog.Flags = OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_EXPLORER | OFN_NOCHANGEDIR; + dialog.Flags = OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_EXPLORER | OFN_NOCHANGEDIR | + OFN_ALLOWMULTISELECT; if (GetOpenFileNameW(&dialog) == FALSE) { return; } - LoadFile(buffer); + + // 多选时首段为目录,其余为文件名;单选时整段即为完整路径。 + const std::wstring first(buffer.data()); + const wchar_t* cursor = buffer.data() + first.size() + 1; + + int loaded = 0; + if (*cursor == L'\0') + { + if (LoadFile(first)) + { + ++loaded; + } + } + else + { + while (*cursor != L'\0') + { + const std::wstring name(cursor); + if (LoadFile(first + L"\\" + name)) + { + ++loaded; + } + cursor += name.size() + 1; + } + } + + if (loaded > 0) + { + SetStatusMessage(L"已加载 " + std::to_wstring(loaded) + + L" 个文件,请在左侧勾选需要查看的信号。", + false); + } } bool Application::LoadFile(const std::wstring& path) { + if (path.empty()) + { + return false; + } + + const int existing = m_dataSet.IndexOfFile(path); + if (existing >= 0) + { + const DataFile* file = m_dataSet.FileAt(existing); + SetStatusMessage(L"该文件已在工作区中:" + (file != nullptr ? file->fileName : path), true); + return false; + } + 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)) + int fileIndex = -1; + if (!m_dataSet.AddFromTable(table, path, error, fileIndex)) { 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); - + // 新文件的信号在全部标签页中默认均为未勾选状态,由用户自行决定查看内容。 + SyncTabsToDataSet(); UpdateWindowTitle(); - InvalidateRect(m_hwnd, nullptr, TRUE); + + const DataFile* file = m_dataSet.FileAt(fileIndex); + if (file != nullptr) + { + std::wstring message = L"已加载 " + file->fileName + L"(" + + FormatCount(static_cast(file->time.size())) + + L" 行采样," + std::to_wstring(file->signals.size()) + + L" 条信号,时间列:" + file->timeColumnName + L")"; + if (!file->timeMonotonic) + { + message += L" 提示:时间列非单调递增,光标取值使用最近邻策略。"; + } + SetStatusMessage(message, false); + } return true; } - void Application::LoadCommandLineFile() + void Application::LoadCommandLineFiles() { int argumentCount = 0; LPWSTR* arguments = CommandLineToArgvW(GetCommandLineW(), &argumentCount); @@ -515,6 +748,7 @@ namespace jdi return; } + int loaded = 0; for (int index = 1; index < argumentCount; ++index) { const std::wstring argument = arguments[index]; @@ -522,10 +756,19 @@ namespace jdi { continue; } - LoadFile(argument); - break; + if (LoadFile(argument)) + { + ++loaded; + } } LocalFree(arguments); + + if (loaded > 0) + { + SetStatusMessage(L"已加载 " + std::to_wstring(loaded) + + L" 个文件,请在左侧勾选需要查看的信号。", + false); + } } void Application::ExecuteToolbarAction(ToolbarAction action) @@ -538,9 +781,6 @@ namespace jdi 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; @@ -548,33 +788,44 @@ namespace jdi m_plot.ZoomTimeStep(1.0 / 1.4); break; case ToolbarAction::MERGE_LANES: - if (m_document.IsLoaded()) + if (!m_dataSet.Empty()) { - m_document.MergeIntoSingleLane(); - m_panel.SetDocument(&m_document); - m_plot.FitAll(); - Relayout(); - SetStatusMessage(L"已将全部信号合并到同一网格。", false); + ActiveTab().SetMode(m_dataSet, LaneMode::MERGED); + SetStatusMessage(L"已将当前标签页的全部可见信号合并到同一网格。", false); } break; case ToolbarAction::SPLIT_LANES: - if (m_document.IsLoaded()) + if (!m_dataSet.Empty()) { - m_document.SplitIntoLanes(); - m_panel.SetDocument(&m_document); - m_plot.FitAll(); - Relayout(); - SetStatusMessage(L"已按信号拆分为独立网格。", false); + ActiveTab().SetMode(m_dataSet, LaneMode::PER_SIGNAL); + SetStatusMessage(L"当前标签页已切换为「每信号一格」。", false); } break; - case ToolbarAction::RESET_VIEW: - m_plot.FitAll(); + case ToolbarAction::SELECT_ALL: + if (!m_dataSet.Empty()) + { + ActiveTab().SetAllVisible(m_dataSet, true); + m_panel.InvalidateRows(); + SetStatusMessage(L"已勾选当前标签页全部信号。", false); + } + break; + case ToolbarAction::CLEAR_ALL: + if (!m_dataSet.Empty()) + { + ActiveTab().SetAllVisible(m_dataSet, false); + m_panel.InvalidateRows(); + SetStatusMessage(L"已清空当前标签页的信号勾选。", false); + } break; case ToolbarAction::NONE: default: break; } - InvalidateRect(m_hwnd, nullptr, TRUE); + + if (m_hwnd != nullptr) + { + InvalidateRect(m_hwnd, nullptr, TRUE); + } } bool Application::HandleKeyDown(WPARAM key) @@ -586,6 +837,26 @@ namespace jdi OpenFileDialog(); return true; } + if (control && key == L'T') + { + NewTab(); + return true; + } + if (control && key == L'W') + { + CloseTab(m_activeTab); + return true; + } + if (control && key == VK_TAB) + { + if (m_tabs.size() > 1) + { + const bool backward = (GetKeyState(VK_SHIFT) & 0x8000) != 0; + const int count = static_cast(m_tabs.size()); + SetActiveTab((m_activeTab + (backward ? count - 1 : 1)) % count); + } + return true; + } switch (key) { @@ -621,11 +892,6 @@ namespace jdi m_plot.FitAll(); return true; } - if (key == L'G') - { - m_plot.FitTime(); - return true; - } return false; } @@ -639,8 +905,8 @@ namespace jdi 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); + const float maximum = + std::min(kMaxPanelWidth, static_cast(kMinClientWidth) / m_scale); m_panelWidth = std::max(kMinPanelWidth, std::min(desired, maximum)); Relayout(); return true; @@ -652,6 +918,8 @@ namespace jdi return m_panel.OnMouseMove(x, y); case Target::TOOLBAR: return m_toolbar.OnMouseMove(x, y); + case Target::TAB_BAR: + return m_tabBar.OnMouseMove(x, y); case Target::NONE: default: return false; @@ -659,12 +927,84 @@ namespace jdi } bool changed = false; - changed = (m_toolbar.HitTest(x, y) ? m_toolbar.OnMouseMove(x, y) : m_toolbar.OnMouseLeave()) || changed; + changed = + (m_tabBar.HitTest(x, y) ? m_tabBar.OnMouseMove(x, y) : m_tabBar.OnMouseLeave()) || changed; + 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; } + void Application::OnTabBarResult(const TabBarResult& result) + { + switch (result.type) + { + case TabBarResult::Type::ADD: + NewTab(); + break; + case TabBarResult::Type::SELECT: + if (result.index != m_activeTab) + { + SetActiveTab(result.index); + } + break; + case TabBarResult::Type::CLOSE: + CloseTab(result.index); + break; + case TabBarResult::Type::CONTEXT: + ShowTabContextMenu(result); + break; + case TabBarResult::Type::NONE: + default: + break; + } + } + + void Application::ShowTabContextMenu(const TabBarResult& result) + { + if (result.index < 0 || result.index >= static_cast(m_tabs.size())) + { + return; + } + + HMENU menu = CreatePopupMenu(); + if (menu == nullptr) + { + return; + } + + AppendMenuW(menu, MF_STRING, static_cast(kMenuTabNew), L"新建标签页"); + AppendMenuW(menu, MF_STRING, static_cast(kMenuTabClose), L"关闭该标签页"); + AppendMenuW(menu, MF_STRING, static_cast(kMenuTabCloseOthers), L"关闭其它标签页"); + if (m_tabs.size() <= 1) + { + EnableMenuItem(menu, static_cast(kMenuTabClose), MF_BYCOMMAND | MF_GRAYED); + EnableMenuItem(menu, static_cast(kMenuTabCloseOthers), MF_BYCOMMAND | MF_GRAYED); + } + + SetForegroundWindow(m_hwnd); + const int command = TrackPopupMenu(menu, TPM_RETURNCMD | TPM_LEFTALIGN | TPM_TOPALIGN, + result.screenX, result.screenY, 0, m_hwnd, nullptr); + DestroyMenu(menu); + + if (command == kMenuTabNew) + { + NewTab(); + } + else if (command == kMenuTabClose) + { + CloseTab(result.index); + } + else if (command == kMenuTabCloseOthers) + { + const TabConfig keep = m_tabs[static_cast(result.index)]; + m_tabs.clear(); + m_tabs.push_back(keep); + SetActiveTab(0); + } + } + LRESULT Application::HandleMessage(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) { switch (message) @@ -731,7 +1071,8 @@ namespace jdi case WM_MOUSELEAVE: { m_trackingLeave = false; - bool changed = m_toolbar.OnMouseLeave(); + bool changed = m_tabBar.OnMouseLeave(); + changed = m_toolbar.OnMouseLeave() || changed; changed = m_panel.OnMouseLeave() || changed; changed = m_plot.OnMouseLeave() || changed; if (changed) @@ -755,6 +1096,15 @@ namespace jdi return 0; } + if (m_tabBar.HitTest(x, y)) + { + SetCapture(hwnd); + m_activeTarget = Target::TAB_BAR; + m_tabBar.OnLButtonDown(x, y); + InvalidateRect(hwnd, nullptr, FALSE); + return 0; + } + if (m_toolbar.HitTest(x, y)) { SetCapture(hwnd); @@ -771,7 +1121,7 @@ namespace jdi m_activeTarget = Target::PANEL; if (m_panel.OnLButtonDown(x, y)) { - InvalidateRect(hwnd, nullptr, FALSE); + InvalidateRect(hwnd, nullptr, TRUE); } return 0; } @@ -796,19 +1146,23 @@ namespace jdi const int y = GET_Y_LPARAM(lParam); ReleaseCapture(); - bool changed = false; switch (m_activeTarget) { case Target::SPLITTER: m_draggingSplitter = false; break; + case Target::TAB_BAR: + { + TabBarResult result; + m_tabBar.OnLButtonUp(x, y, result); + m_activeTarget = Target::NONE; + OnTabBarResult(result); + return 0; + } case Target::TOOLBAR: { ToolbarAction action = ToolbarAction::NONE; - if (m_toolbar.OnLButtonUp(x, y, action)) - { - changed = true; - } + m_toolbar.OnLButtonUp(x, y, action); m_activeTarget = Target::NONE; if (action != ToolbarAction::NONE) { @@ -818,10 +1172,10 @@ namespace jdi break; } case Target::PANEL: - changed = m_panel.OnLButtonUp(x, y) || changed; + m_panel.OnLButtonUp(x, y); break; case Target::PLOT: - changed = m_plot.OnLButtonUp(x, y) || changed; + m_plot.OnLButtonUp(x, y); break; case Target::NONE: default: @@ -829,11 +1183,7 @@ namespace jdi } m_activeTarget = Target::NONE; - changed = true; - if (changed) - { - InvalidateRect(hwnd, nullptr, FALSE); - } + InvalidateRect(hwnd, nullptr, TRUE); return 0; } case WM_LBUTTONDBLCLK: @@ -851,7 +1201,7 @@ namespace jdi } if (changed) { - InvalidateRect(hwnd, nullptr, FALSE); + InvalidateRect(hwnd, nullptr, TRUE); } return 0; } @@ -860,13 +1210,26 @@ namespace jdi const int x = GET_X_LPARAM(lParam); const int y = GET_Y_LPARAM(lParam); + POINT cursor = {x, y}; + ClientToScreen(hwnd, &cursor); + + if (m_tabBar.HitTest(x, y)) + { + TabBarResult result; + if (m_tabBar.OnRButtonDown(x, y, cursor.x, cursor.y, result)) + { + OnTabBarResult(result); + } + return 0; + } + if (m_panel.HitTest(x, y)) { SetCapture(hwnd); m_activeTarget = Target::PANEL; if (m_panel.OnRButtonDown(x, y)) { - InvalidateRect(hwnd, nullptr, FALSE); + InvalidateRect(hwnd, nullptr, TRUE); } ReleaseCapture(); m_activeTarget = Target::NONE; @@ -955,23 +1318,44 @@ namespace jdi case WM_KEYDOWN: if (HandleKeyDown(wParam)) { - InvalidateRect(hwnd, nullptr, FALSE); + InvalidateRect(hwnd, nullptr, TRUE); return 0; } break; + case kMessageRemoveFile: + { + RemoveFile(static_cast(wParam)); + return 0; + } case WM_DROPFILES: { HDROP drop = reinterpret_cast(wParam); - const UINT length = DragQueryFileW(drop, 0, nullptr, 0); - if (length > 0) + const UINT count = DragQueryFileW(drop, 0xFFFFFFFF, nullptr, 0); + int loaded = 0; + for (UINT index = 0; index < count; ++index) { - std::vector buffer(static_cast(length) + 1, L'\0'); - if (DragQueryFileW(drop, 0, buffer.data(), length + 1) > 0) + const UINT length = DragQueryFileW(drop, index, nullptr, 0); + if (length == 0) { - LoadFile(std::wstring(buffer.data())); + continue; + } + std::vector buffer(static_cast(length) + 1, L'\0'); + if (DragQueryFileW(drop, index, buffer.data(), length + 1) > 0) + { + if (LoadFile(std::wstring(buffer.data()))) + { + ++loaded; + } } } DragFinish(drop); + + if (loaded > 0) + { + SetStatusMessage(L"已加载 " + std::to_wstring(loaded) + + L" 个文件,请在左侧勾选需要查看的信号。", + false); + } return 0; } case WM_DESTROY: diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index aa275ce..464f2f1 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -8,8 +8,8 @@ #include #include +#include "DataModel.hpp" #include "PaintUtil.hpp" -#include "SignalSet.hpp" #include "Theme.hpp" namespace jdi @@ -29,6 +29,9 @@ namespace jdi /** * @brief 波形绘图区:网格、时间轴、光标、缩放与自适应。 + * + * 绘图区同时依赖数据层(DataSet,只读)与当前标签页配置(TabConfig,可写), + * 视图范围、网格滚动与时间光标均保存在标签页配置中,因此各标签页互不干扰。 */ class PlotView { @@ -43,10 +46,11 @@ namespace jdi void Setup(const UiFonts* fonts, const Theme* theme); /** - * @brief 绑定数据集。 - * @param [IN] document 数据集指针,允许为空。 + * @brief 绑定数据层与当前标签页配置。 + * @param [IN] dataSet 数据集合,允许为空。 + * @param [IN] tab 当前标签页配置,允许为空。 */ - void SetDocument(SignalSet* document); + void SetContext(const DataSet* dataSet, TabConfig* tab); /** * @brief 设置 DPI 缩放比例。 @@ -149,12 +153,7 @@ namespace jdi HCURSOR QueryCursor(int x, int y) const; /** - * @brief 时间轴自适应到全部数据范围。 - */ - void FitTime(); - - /** - * @brief 时间轴与幅值轴同时自适应。 + * @brief 时间轴与幅值轴同时自适应到可见信号范围。 */ void FitAll(); @@ -180,7 +179,7 @@ namespace jdi * @brief 获取网格当前内容总高度。 * @return 内容高度(像素)。 */ - float LaneContentHeight() const; + float LaneContentHeight() const { return m_laneContentHeight; } /** * @brief 清除已固定的时间光标。 @@ -191,31 +190,30 @@ namespace jdi * @brief 判断当前是否存在激活的时间光标。 * @return 存在返回 true。 */ - bool HasCursor() const { return m_cursorVisible; } + bool HasCursor() const; /** * @brief 获取当前激活光标对应的时间。 * @return 光标时间。 */ - double CursorTime() const { return m_cursorTime; } + double CursorTime() const; /** * @brief 获取当前视图左边界时间。 * @return 视图左边界时间。 */ - double ViewMinTime() const { return m_viewMin; } + double ViewMinTime() const; /** * @brief 获取当前视图右边界时间。 * @return 视图右边界时间。 */ - double ViewMaxTime() const { return m_viewMax; } + double ViewMaxTime() const; /** - * @brief 获取绘图区(波形区域)矩形。 - * @return 波形区域矩形。 + * @brief 重新生成网格几何(可见性或网格划分变化后调用)。 */ - const gd::RectF& WaveformRect() const { return m_plotRect; } + void RefreshLanes(); private: /** @@ -226,9 +224,57 @@ namespace jdi gd::RectF rect; /**< 网格波形区域。 */ double yMin = 0.0; /**< 幅值下限。 */ double yMax = 1.0; /**< 幅值上限。 */ - bool empty = true; /**< 是否没有可见信号。 */ }; + /** + * @brief 已解析的信号视图:数据与显示配置的组合。 + */ + struct ResolvedSignal + { + const SignalData* data = nullptr; /**< 信号数据。 */ + const SignalStyle* style = nullptr; /**< 显示配置。 */ + const DataFile* file = nullptr; /**< 所属文件。 */ + }; + + /** + * @brief 获取当前生效的标签页配置(未绑定时返回内部占位对象)。 + * @return 标签页配置引用。 + */ + TabConfig& Tab() { return (m_tab != nullptr) ? *m_tab : m_localTab; } + + /** + * @brief 获取当前生效的标签页配置(只读)。 + * @return 标签页配置引用。 + */ + const TabConfig& Tab() const { return (m_tab != nullptr) ? *m_tab : m_localTab; } + + /** + * @brief 判断是否存在可绘制内容(已加载数据且至少有一条可见信号)。 + * @return 可绘制返回 true。 + */ + bool HasDrawableContent() const; + + /** + * @brief 计算当前视图应使用的时间范围。 + * @param [OUT] low 时间下限。 + * @param [OUT] high 时间上限。 + */ + void TimeBounds(double& low, double& high) const; + + /** + * @brief 解析信号引用,得到数据与显示配置。 + * @param [IN] ref 信号引用。 + * @return 解析结果,引用无效时各指针为空。 + */ + ResolvedSignal ResolveSignal(const SignalRef& ref) const; + + /** + * @brief 判断某网格内的可见信号是否来自多个文件。 + * @param [IN] laneIndex 网格下标。 + * @return 跨越多个文件返回 true。 + */ + bool LaneSpansMultipleFiles(int laneIndex) const; + /** * @brief 重新计算各网格的布局与幅值范围。 */ @@ -290,13 +336,13 @@ namespace jdi /** * @brief 构建单条信号在当前视图下的折线点序列。 - * @param [IN] signal 目标信号。 + * @param [IN] resolved 已解析的信号视图。 * @param [IN] lane 所属网格绘制信息。 * @param [IN] stepMode 是否使用阶梯保持连线。 * @param [OUT] points 输出点序列。 * @param [OUT] breaks 输出每段折线的起始下标(用于处理缺失值断线)。 */ - void BuildPolyline(const Signal& signal, const LaneVisual& lane, bool stepMode, + void BuildPolyline(const ResolvedSignal& resolved, const LaneVisual& lane, bool stepMode, std::vector& points, std::vector& breaks) const; /** @@ -307,8 +353,8 @@ namespace jdi * @param [IN] timeTicks 时间刻度序列。 * @param [IN] timeStep 时间刻度步长。 */ - void PaintLane(gd::Graphics& g, int laneIndex, const LaneVisual& lane, const std::vector& timeTicks, - double timeStep); + void PaintLane(gd::Graphics& g, int laneIndex, const LaneVisual& lane, + const std::vector& timeTicks, double timeStep); /** * @brief 绘制底部时间轴。 @@ -347,9 +393,17 @@ namespace jdi */ void PaintLaneReadout(gd::Graphics& g, int laneIndex, const LaneVisual& lane); + /** + * @brief 绘制空状态提示文本。 + * @param [IN,OUT] g 绘图上下文。 + */ + void PaintEmptyState(gd::Graphics& g); + const UiFonts* m_fonts = nullptr; /**< 字体资源。 */ const Theme* m_theme = nullptr; /**< 主题资源。 */ - SignalSet* m_document = nullptr; /**< 数据集。 */ + const DataSet* m_dataSet = nullptr; /**< 数据集合。 */ + TabConfig* m_tab = nullptr; /**< 当前标签页配置。 */ + TabConfig m_localTab; /**< 未绑定标签页时使用的占位配置。 */ float m_scale = 1.0f; /**< DPI 缩放比例。 */ gd::RectF m_area; /**< 绘图区整体区域。 */ @@ -357,21 +411,13 @@ namespace jdi gd::RectF m_timeAxisRect; /**< 时间轴区域。 */ gd::RectF m_scrollBarRect; /**< 网格滚动条区域。 */ - float m_yAxisWidth = 64.0f; /**< 纵轴标签列宽度(逻辑像素)。 */ - float m_timeAxisHeight = 34.0f; /**< 时间轴高度(逻辑像素)。 */ + float m_yAxisWidth = 74.0f; /**< 纵轴标签列宽度(逻辑像素)。 */ + float m_timeAxisHeight = 32.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; /**< 拖拽过程中是否发生了位移。 */ @@ -379,8 +425,6 @@ namespace jdi 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; /**< 拖拽起始幅值下限。 */ diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index 3321c80..f9a3b81 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -11,16 +11,16 @@ 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; /**< 允许的最小时间跨度。 */ + 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 将数值限制到合法区间。 @@ -41,6 +41,26 @@ namespace jdi } return value; } + + /** + * @brief 组装图例文本:信号名已在括号中携带单位时不再重复追加。 + * @param [IN] data 信号数据。 + * @return 图例文本。 + */ + std::wstring BuildLegendText(const SignalData& data) + { + if (data.unit.empty()) + { + return data.name; + } + + const std::wstring bracketed = L"(" + data.unit + L")"; + if (ContainsInsensitive(data.name, bracketed)) + { + return data.name; + } + return data.name + L" [" + data.unit + L"]"; + } } PlotView::PlotView() = default; @@ -51,17 +71,13 @@ namespace jdi m_theme = theme; } - void PlotView::SetDocument(SignalSet* document) + void PlotView::SetContext(const DataSet* dataSet, TabConfig* tab) { - m_document = document; - m_laneScroll = 0.0f; - m_cursorVisible = false; - m_cursorPinned = false; + m_dataSet = dataSet; + m_tab = tab; m_hoverLane = -1; - if (m_document != nullptr && m_document->IsLoaded()) - { - FitTime(); - } + m_dragMode = PlotDragMode::NONE; + RefreshLanes(); } void PlotView::SetScale(float scale) @@ -69,6 +85,71 @@ namespace jdi m_scale = (scale > 0.1f) ? scale : 1.0f; } + bool PlotView::HasCursor() const + { + return Tab().CursorVisible(); + } + + double PlotView::CursorTime() const + { + return Tab().CursorTime(); + } + + double PlotView::ViewMinTime() const + { + return Tab().ViewMin(); + } + + double PlotView::ViewMaxTime() const + { + return Tab().ViewMax(); + } + + bool PlotView::HasDrawableContent() const + { + return (m_dataSet != nullptr) && !m_dataSet->Empty() && (m_tab != nullptr) && m_tab->AnyVisible(); + } + + void PlotView::TimeBounds(double& low, double& high) const + { + low = 0.0; + high = 1.0; + + if (m_dataSet == nullptr || m_dataSet->Empty()) + { + return; + } + + // 优先使用可见信号所覆盖的范围,使视图随勾选内容自适应。 + if (m_tab != nullptr && m_tab->ComputeVisibleTimeBounds(*m_dataSet, low, high)) + { + return; + } + + low = m_dataSet->MinTime(); + high = m_dataSet->MaxTime(); + if (!(high > low)) + { + high = low + 1.0; + } + } + + PlotView::ResolvedSignal PlotView::ResolveSignal(const SignalRef& ref) const + { + ResolvedSignal result; + if (m_dataSet == nullptr) + { + return result; + } + result.data = m_dataSet->Resolve(ref); + result.file = m_dataSet->FileAt(ref.fileIndex); + if (m_tab != nullptr) + { + result.style = m_tab->Style(ref); + } + return result; + } + void PlotView::Layout(const gd::RectF& area) { m_area = area; @@ -82,11 +163,7 @@ namespace jdi 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(); - } + std::size_t laneCount = (m_tab != nullptr) ? m_tab->Lanes().size() : 0; if (laneCount > 0) { const float needed = static_cast(laneCount) * m_minLaneHeight + @@ -98,40 +175,68 @@ namespace jdi m_scrollBarVisible = false; } - const float rightReserve = m_scrollBarVisible ? (kScrollBarThickness * m_scale + 6.0f * m_scale) - : (6.0f * m_scale); + 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_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 + void PlotView::RefreshLanes() { - return m_laneContentHeight; + BuildLaneVisuals(); } void PlotView::ClampLaneScroll() { const float maxOffset = std::max(0.0f, m_laneContentHeight - m_plotRect.Height); - m_laneScroll = ClampDouble(m_laneScroll, 0.0, maxOffset); + Tab().LaneScroll() = static_cast(ClampDouble(Tab().LaneScroll(), 0.0, maxOffset)); + } + + bool PlotView::LaneSpansMultipleFiles(int laneIndex) const + { + if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) + { + return false; + } + + int firstFile = -1; + for (const SignalRef& ref : m_tab->Lanes()[static_cast(laneIndex)].signals) + { + const SignalStyle* style = m_tab->Style(ref); + if (style == nullptr || !style->visible) + { + continue; + } + if (firstFile < 0) + { + firstFile = ref.fileIndex; + } + else if (ref.fileIndex != firstFile) + { + return true; + } + } + return false; } void PlotView::BuildLaneVisuals() { m_lanes.clear(); - if (m_document == nullptr || !m_document->IsLoaded()) + + if (m_tab == nullptr || m_dataSet == nullptr || !HasDrawableContent()) { m_laneContentHeight = 0.0f; return; } - const std::vector& lanes = m_document->Lanes(); + const std::vector& lanes = m_tab->Lanes(); const std::size_t count = lanes.size(); if (count == 0) { @@ -140,8 +245,8 @@ namespace jdi } const float gap = m_laneGap * m_scale; - float laneHeight = (m_plotRect.Height - gap * static_cast(count - 1)) / - static_cast(count); + float laneHeight = + (m_plotRect.Height - gap * static_cast(count - 1)) / static_cast(count); if (laneHeight < m_minLaneHeight * m_scale) { laneHeight = m_minLaneHeight * m_scale; @@ -149,51 +254,46 @@ namespace jdi m_laneContentHeight = laneHeight * static_cast(count) + gap * static_cast(count - 1); ClampLaneScroll(); + const float scrollOffset = Tab().LaneScroll(); + 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); + visual.rect = gd::RectF( + m_plotRect.X, + m_plotRect.Y + static_cast(index) * (laneHeight + gap) - scrollOffset, + m_plotRect.Width, laneHeight); + m_tab->ComputeLaneRange(*m_dataSet, 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; + const double viewMin = Tab().ViewMin(); + const double viewMax = Tab().ViewMax(); + const double span = viewMax - 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); + return m_plotRect.X + static_cast((time - viewMin) / span * m_plotRect.Width); } double PlotView::XToTime(float x) const { + const double viewMin = Tab().ViewMin(); if (m_plotRect.Width <= 0.0f) { - return m_viewMin; + return viewMin; } const double ratio = static_cast(x - m_plotRect.X) / static_cast(m_plotRect.Width); - return m_viewMin + ratio * (m_viewMax - m_viewMin); + return viewMin + ratio * (Tab().ViewMax() - viewMin); } float PlotView::ValueToY(double value, const LaneVisual& lane) const @@ -235,114 +335,119 @@ namespace jdi void PlotView::ClampTimeView() { - if (m_document == nullptr || !m_document->IsLoaded()) + if (m_dataSet == nullptr || m_dataSet->Empty()) { return; } - double span = m_viewMax - m_viewMin; + double span = Tab().ViewMax() - Tab().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; + double dataMin = 0.0; + double dataMax = 1.0; + TimeBounds(dataMin, dataMax); - if (m_viewMin < minAllowed) + const double pad = span * 2.0; + const double minAllowed = dataMin - pad; + const double maxAllowed = dataMax + pad; + + if (Tab().ViewMin() < minAllowed) { - m_viewMin = minAllowed; - m_viewMax = m_viewMin + span; + Tab().ViewMin() = minAllowed; + Tab().ViewMax() = Tab().ViewMin() + span; } - if (m_viewMax > maxAllowed) + if (Tab().ViewMax() > maxAllowed) { - m_viewMax = maxAllowed; - m_viewMin = m_viewMax - span; + Tab().ViewMax() = maxAllowed; + Tab().ViewMin() = Tab().ViewMax() - span; } - (void)dataSpan; } void PlotView::ZoomTimeAt(double factor, double anchorTime) { - if (m_document == nullptr || !m_document->IsLoaded() || !(factor > 0.0)) + if (m_dataSet == nullptr || m_dataSet->Empty() || !(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; + double dataMin = 0.0; + double dataMax = 1.0; + TimeBounds(dataMin, dataMax); + const double dataSpan = std::max(dataMax - dataMin, kMinTimeSpan); - const double span = m_viewMax - m_viewMin; - double newSpan = span / factor; - newSpan = ClampDouble(newSpan, minSpan, maxSpan); + const double span = Tab().ViewMax() - Tab().ViewMin(); + double newSpan = ClampDouble(span / factor, dataSpan * 1.0e-9, dataSpan * 100.0); - const double ratio = (span > 0.0) ? (anchorTime - m_viewMin) / span : 0.5; - m_viewMin = anchorTime - ratio * newSpan; - m_viewMax = m_viewMin + newSpan; + const double ratio = (span > 0.0) ? (anchorTime - Tab().ViewMin()) / span : 0.5; + Tab().ViewMin() = anchorTime - ratio * newSpan; + Tab().ViewMax() = Tab().ViewMin() + newSpan; ClampTimeView(); } void PlotView::ZoomTimeStep(double factor) { - const double anchor = (m_viewMin + m_viewMax) * 0.5; + const double anchor = (Tab().ViewMin() + Tab().ViewMax()) * 0.5; ZoomTimeAt(factor, anchor); } void PlotView::PanTimeByFraction(double fraction) { - const double span = m_viewMax - m_viewMin; + const double span = Tab().ViewMax() - Tab().ViewMin(); const double delta = span * fraction; - m_viewMin += delta; - m_viewMax += delta; + Tab().ViewMin() += delta; + Tab().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; - } + Tab().LaneScroll() += static_cast(fraction * static_cast(m_laneContentHeight)); + // 立即重建几何,避免命中测试使用旧位置。 + BuildLaneVisuals(); } void PlotView::FitAll() { - FitTime(); - m_laneScroll = 0.0f; - if (m_document != nullptr) + if (m_dataSet == nullptr || m_dataSet->Empty()) { - for (Lane& lane : m_document->Lanes()) + Tab().ViewMin() = 0.0; + Tab().ViewMax() = 1.0; + Tab().ViewInitialized() = true; + return; + } + + double low = 0.0; + double high = 1.0; + TimeBounds(low, high); + if (!(high > low)) + { + const double padding = std::max(1.0e-6, std::fabs(low) * 0.01); + low -= padding; + high += padding; + } + + Tab().ViewMin() = low; + Tab().ViewMax() = high; + Tab().ViewInitialized() = true; + Tab().LaneScroll() = 0.0f; + + if (m_tab != nullptr) + { + for (Lane& lane : m_tab->Lanes()) { lane.autoRange = true; } } + BuildLaneVisuals(); } void PlotView::ClearCursor() { - m_cursorVisible = false; - m_cursorPinned = false; + Tab().CursorVisible() = false; + Tab().CursorPinned() = false; } bool PlotView::HitTest(int x, int y) const @@ -364,11 +469,13 @@ namespace jdi break; } - if (m_scrollBarVisible && UiPointInRect(m_scrollBarRect, static_cast(x), static_cast(y))) + 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 (static_cast(x) < m_plotRect.X && + UiPointInRect(m_area, static_cast(x), static_cast(y))) { if (LaneAtY(y) >= 0) { @@ -384,7 +491,7 @@ namespace jdi bool PlotView::OnMouseMove(int x, int y) { - if (m_document == nullptr || !m_document->IsLoaded()) + if (!HasDrawableContent()) { return false; } @@ -400,10 +507,11 @@ namespace jdi { 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; + const double span = Tab().ViewMax() - Tab().ViewMin(); + const double delta = + -static_cast(x - m_dragLastX) / static_cast(m_plotRect.Width) * span; + Tab().ViewMin() += delta; + Tab().ViewMax() += delta; ClampTimeView(); m_dragLastX = x; m_dragLastY = y; @@ -434,12 +542,11 @@ namespace jdi 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); + const double scaleRef = std::max(std::fabs(m_dragStartYMax), std::fabs(m_dragStartYMin)); + const double minSpan = std::max(scaleRef * 1.0e-9, 1.0e-12); + const double newSpan = ClampDouble(span / factor, minSpan, scaleRef * 100.0 + 1.0); - Lane& lane = m_document->Lanes()[static_cast(m_dragLane)]; + Lane& lane = m_tab->Lanes()[static_cast(m_dragLane)]; lane.autoRange = false; lane.yMin = m_dragAnchorValue - m_dragAnchorRatio * newSpan; lane.yMax = lane.yMin + newSpan; @@ -464,11 +571,12 @@ namespace jdi { return false; } - const double travel = static_cast(y - m_dragLastY) / static_cast(m_plotRect.Height); - m_laneScroll += static_cast(travel * m_laneContentHeight); - ClampLaneScroll(); + const double travel = + static_cast(y - m_dragLastY) / static_cast(m_plotRect.Height); + Tab().LaneScroll() += static_cast(travel * m_laneContentHeight); m_dragLastY = y; m_dragMoved = true; + BuildLaneVisuals(); return true; } case PlotDragMode::NONE: @@ -486,13 +594,13 @@ namespace jdi } const bool insidePlot = UiPointInRect(m_plotRect, fx, fy); - if (insidePlot && !m_cursorPinned) + if (insidePlot && !Tab().CursorPinned()) { const double time = XToTime(fx); - if (!m_cursorVisible || std::fabs(time - m_cursorTime) > 0.0) + if (!Tab().CursorVisible() || std::fabs(time - Tab().CursorTime()) > 0.0) { - m_cursorTime = time; - m_cursorVisible = true; + Tab().CursorTime() = time; + Tab().CursorVisible() = true; changed = true; } } @@ -501,7 +609,7 @@ namespace jdi bool PlotView::OnLButtonDown(int x, int y) { - if (m_document == nullptr || !m_document->IsLoaded()) + if (!HasDrawableContent()) { return false; } @@ -511,10 +619,9 @@ namespace jdi 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))) + if (m_scrollBarVisible && + UiPointInRect(m_scrollBarRect, static_cast(x), static_cast(y))) { m_dragMode = PlotDragMode::SCROLL_LANES; return true; @@ -533,7 +640,7 @@ namespace jdi m_dragLane = laneIndex; const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; - Lane& lane = m_document->Lanes()[static_cast(laneIndex)]; + Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; lane.autoRange = false; lane.yMin = visual.yMin; lane.yMax = visual.yMax; @@ -564,16 +671,16 @@ namespace jdi 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) + if (Tab().CursorPinned() && std::fabs(time - Tab().CursorTime()) < 1.0e-12) { - m_cursorPinned = false; - m_cursorVisible = false; + Tab().CursorPinned() = false; + Tab().CursorVisible() = false; } else { - m_cursorTime = time; - m_cursorVisible = true; - m_cursorPinned = true; + Tab().CursorTime() = time; + Tab().CursorVisible() = true; + Tab().CursorPinned() = true; } changed = true; } @@ -586,7 +693,7 @@ namespace jdi bool PlotView::OnRButtonDown(int x, int y) { - if (m_document == nullptr || !m_document->IsLoaded()) + if (!HasDrawableContent()) { return false; } @@ -623,10 +730,10 @@ namespace jdi if (!moved || box.Width < 6.0f * m_scale) { - // 未形成有效框:清除已固定光标。 - if (!m_cursorPinned) + // 未形成有效框:清除未固定的光标。 + if (!Tab().CursorPinned()) { - m_cursorVisible = false; + Tab().CursorVisible() = false; } return true; } @@ -639,20 +746,19 @@ namespace jdi } if (timeB - timeA > 0.0) { - m_viewMin = timeA; - m_viewMax = timeB; + Tab().ViewMin() = timeA; + Tab().ViewMax() = timeB; ClampTimeView(); } - if (laneIndex >= 0 && laneIndex < static_cast(m_lanes.size()) && - box.Height > 12.0f * m_scale) + 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& lane = m_tab->Lanes()[static_cast(laneIndex)]; lane.autoRange = false; lane.yMin = std::min(valueTop, valueBottom); lane.yMax = std::max(valueTop, valueBottom); @@ -664,7 +770,7 @@ namespace jdi bool PlotView::OnMouseWheel(int x, int y, int delta, bool ctrl, bool shift, bool alt) { - if (m_document == nullptr || !m_document->IsLoaded() || delta == 0) + if (!HasDrawableContent() || delta == 0) { return false; } @@ -673,17 +779,17 @@ namespace jdi if (alt) { - m_laneScroll -= static_cast(notches * 48.0 * m_scale); - ClampLaneScroll(); + Tab().LaneScroll() -= static_cast(notches * 48.0 * m_scale); + BuildLaneVisuals(); return true; } if (shift) { - const double span = m_viewMax - m_viewMin; + const double span = Tab().ViewMax() - Tab().ViewMin(); const double sweep = -notches * span * 0.15; - m_viewMin += sweep; - m_viewMax += sweep; + Tab().ViewMin() += sweep; + Tab().ViewMax() += sweep; ClampTimeView(); return true; } @@ -701,8 +807,7 @@ namespace jdi } const LaneVisual& visual = m_lanes[static_cast(laneIndex)]; - Lane& lane = m_document->Lanes()[static_cast(laneIndex)]; - LaneVisual mutableVisual = visual; + Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; if (lane.autoRange) { lane.autoRange = false; @@ -710,7 +815,7 @@ namespace jdi lane.yMin = visual.yMin; lane.yMax = visual.yMax; - const double anchorValue = YToValue(static_cast(y), mutableVisual); + const double anchorValue = YToValue(static_cast(y), visual); const double span = visual.yMax - visual.yMin; if (!(span > 0.0)) { @@ -718,10 +823,9 @@ namespace jdi } 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 newSpan = ClampDouble(span / factor, minSpan, scaleRef * 100.0 + 1.0); const double ratio = (visual.yMax - anchorValue) / span; lane.yMin = anchorValue - ratio * newSpan; @@ -752,46 +856,48 @@ namespace jdi m_hoverLane = -1; changed = true; } - if (!m_cursorPinned && m_cursorVisible) + if (!Tab().CursorPinned() && Tab().CursorVisible()) { - m_cursorVisible = false; + Tab().CursorVisible() = false; changed = true; } return changed; } - void PlotView::BuildPolyline(const Signal& signal, const LaneVisual& lane, bool stepMode, + void PlotView::BuildPolyline(const ResolvedSignal& resolved, const LaneVisual& lane, bool stepMode, std::vector& points, std::vector& breaks) const { points.clear(); breaks.clear(); - if (m_document == nullptr) + if (resolved.data == nullptr || resolved.file == nullptr) { return; } - const std::vector& time = m_document->Time(); - const std::vector& values = signal.values; + const std::vector& time = resolved.file->time; + const std::vector& values = resolved.data->values; const std::size_t count = std::min(time.size(), values.size()); if (count == 0 || m_plotRect.Width <= 1.0f) { return; } + const double viewMin = Tab().ViewMin(); + const double viewMax = Tab().ViewMax(); + std::size_t begin = 0; std::size_t end = count; - if (m_document->TimeMonotonic()) + if (resolved.file->timeMonotonic) { - const std::vector::const_iterator first = - std::lower_bound(time.begin(), time.end(), m_viewMin); + const std::vector::const_iterator first = std::lower_bound(time.begin(), time.end(), 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); + const std::vector::const_iterator last = std::lower_bound(time.begin(), time.end(), viewMax); end = static_cast(last - time.begin()); if (end < count) { @@ -829,7 +935,7 @@ namespace jdi const float x = TimeToX(t); float y = ValueToY(value, lane); - y = ClampDouble(y, limitTop, limitBottom); + y = static_cast(ClampDouble(y, limitTop, limitBottom)); if (!inSegment) { @@ -851,6 +957,20 @@ namespace jdi } } + void PlotView::PaintEmptyState(gd::Graphics& g) + { + std::wstring message; + if (m_dataSet == nullptr || m_dataSet->Empty()) + { + message = L"打开 CSV 文件以查看仿真数据波形"; + } + else + { + message = L"请在左侧勾选需要查看的信号(可同时加载多个文件进行对比)"; + } + UiText(g, message, m_fonts->ui, m_area, m_theme->textMuted, TextAlign::CENTER); + } + void PlotView::Paint(gd::Graphics& g) { if (m_theme == nullptr || m_fonts == nullptr) @@ -860,21 +980,22 @@ namespace jdi UiFillRect(g, m_area, m_theme->plotBg); - if (m_document == nullptr || !m_document->IsLoaded()) + BuildLaneVisuals(); + + if (m_lanes.empty()) { - UiText(g, L"打开 CSV 文件以查看仿真数据波形", m_fonts->ui, m_area, m_theme->textMuted, - TextAlign::CENTER); + PaintEmptyState(g); return; } - BuildLaneVisuals(); - - const double span = m_viewMax - m_viewMin; + const double viewMin = Tab().ViewMin(); + const double viewMax = Tab().ViewMax(); + const double span = viewMax - viewMin; const double timeStep = NiceStep((span > 0.0) ? span : 1.0, kTimeTickTarget); std::vector timeTicks; - BuildTicks(m_viewMin, m_viewMax, timeStep, timeTicks); + BuildTicks(viewMin, viewMax, timeStep, timeTicks); - // 波形区域边框。 + // 波形区域底色。 UiFillRect(g, m_plotRect, m_theme->cardBg); for (std::size_t index = 0; index < m_lanes.size(); ++index) @@ -954,7 +1075,8 @@ namespace jdi } // 波形绘制。 - if (m_document->Lanes()[static_cast(laneIndex)].signalIndices.empty()) + const std::vector& laneSignals = m_tab->Lanes()[static_cast(laneIndex)].signals; + if (laneSignals.empty()) { UiText(g, L"(空网格)", m_fonts->uiSmall, lane.rect, m_theme->textMuted, TextAlign::CENTER); } @@ -966,27 +1088,24 @@ namespace jdi std::vector points; std::vector breaks; - for (const int signalIndex : m_document->Lanes()[static_cast(laneIndex)].signalIndices) + for (const SignalRef& ref : laneSignals) { - if (signalIndex < 0 || signalIndex >= static_cast(m_document->Signals().size())) - { - continue; - } - const Signal& signal = m_document->Signals()[static_cast(signalIndex)]; - if (!signal.visible) + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || resolved.style == nullptr || !resolved.style->visible) { continue; } - const bool stepMode = (signal.mode == DrawMode::STEP); - BuildPolyline(signal, lane, stepMode, points, breaks); + const DrawMode mode = resolved.style->mode; + const bool stepMode = (mode == DrawMode::STEP); + BuildPolyline(resolved, lane, stepMode, points, breaks); - const bool drawLine = (signal.mode != DrawMode::SCATTER); - const bool drawScatter = (signal.mode == DrawMode::SCATTER || signal.mode == DrawMode::LINE_SCATTER); + const bool drawLine = (mode != DrawMode::SCATTER); + const bool drawScatter = (mode == DrawMode::SCATTER || mode == DrawMode::LINE_SCATTER); if (drawLine && !breaks.empty()) { - gd::Pen pen(ToGdiColor(signal.color), signal.lineWidth * m_scale); + gd::Pen pen(ToGdiColor(resolved.style->color), resolved.style->lineWidth * m_scale); pen.SetLineJoin(stepMode ? gd::LineJoinMiter : gd::LineJoinRound); pen.SetStartCap(gd::LineCapRound); pen.SetEndCap(gd::LineCapRound); @@ -1006,11 +1125,12 @@ namespace jdi if (drawScatter && !points.empty()) { const float radius = static_cast( - ClampDouble(std::max(1.0f, signal.lineWidth) * (kMinScatterRadius + 0.6f), + ClampDouble(std::max(1.0f, resolved.style->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)); + const std::size_t scatterStride = + std::max(1, points.size() / kMaxScatterPoints); + gd::SolidBrush brush(ToGdiColor(resolved.style->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, @@ -1030,7 +1150,7 @@ namespace jdi if (lane.rect.Height >= 24.0f * m_scale) { PaintLaneLegend(g, laneIndex, lane); - if (m_cursorVisible && !lane.empty && lane.rect.Height >= 40.0f * m_scale) + if (Tab().CursorVisible() && lane.rect.Height >= 40.0f * m_scale) { PaintLaneReadout(g, laneIndex, lane); } @@ -1042,12 +1162,14 @@ namespace jdi 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())) + if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return; } + const std::vector& laneSignals = m_tab->Lanes()[static_cast(laneIndex)].signals; + const bool multiFile = LaneSpansMultipleFiles(laneIndex); + 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; @@ -1059,25 +1181,21 @@ namespace jdi // 先测量图例总宽度,再用半透明底板掠开底下的网格线,提升可读性。 float legendWidth = 0.0f; - for (const int signalIndex : lanes[static_cast(laneIndex)].signalIndices) + for (const SignalRef& ref : laneSignals) { - if (signalIndex < 0 || signalIndex >= static_cast(m_document->Signals().size())) - { - continue; - } - const Signal& signal = m_document->Signals()[static_cast(signalIndex)]; - if (!signal.visible) + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || resolved.style == nullptr || !resolved.style->visible) { continue; } - std::wstring label = signal.name; - if (!signal.unit.empty()) + std::wstring label = BuildLegendText(*resolved.data); + if (multiFile && resolved.file != nullptr) { - label += L" [" + signal.unit + L"]"; + label += L" · " + resolved.file->fileName; } - const float itemWidth = 8.0f * m_scale + 5.0f * m_scale + - UiTextWidth(g, label, m_fonts->uiSmall) + 14.0f * m_scale; + const float itemWidth = 13.0f * m_scale + UiTextWidth(g, label, m_fonts->uiSmall) + + 14.0f * m_scale; if (cursorX + legendWidth + itemWidth > maxX) { break; @@ -1087,34 +1205,29 @@ namespace jdi 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), + 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) + for (const SignalRef& ref : laneSignals) { - if (signalIndex < 0 || signalIndex >= static_cast(m_document->Signals().size())) - { - continue; - } - const Signal& signal = m_document->Signals()[static_cast(signalIndex)]; - if (!signal.visible) + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || resolved.style == nullptr || !resolved.style->visible) { continue; } anyVisible = true; ++visibleCount; - std::wstring label = signal.name; - if (!signal.unit.empty()) + std::wstring label = BuildLegendText(*resolved.data); + if (multiFile && resolved.file != nullptr) { - label += L" [" + signal.unit + L"]"; + label += L" · " + resolved.file->fileName; } - 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; + const float itemWidth = 13.0f * m_scale + textWidth + 14.0f * m_scale; if (drawnWidth + itemWidth > legendWidth + 0.5f) { break; @@ -1122,12 +1235,13 @@ namespace jdi 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); + UiFillRoundRect(g, + gd::RectF(cursorX, top + (height - swatchSize) * 0.5f, swatchSize, swatchSize), + 1.5f * m_scale, resolved.style->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); + 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; } @@ -1152,13 +1266,13 @@ namespace jdi 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())) + if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return; } - if (m_cursorTime < m_viewMin || m_cursorTime > m_viewMax) + const double cursorTime = Tab().CursorTime(); + if (cursorTime < Tab().ViewMin() || cursorTime > Tab().ViewMax()) { return; } @@ -1166,34 +1280,33 @@ namespace jdi struct ReadoutLine { std::wstring text; - std::uint32_t color; - float value; }; std::vector lines; - for (const int signalIndex : lanes[static_cast(laneIndex)].signalIndices) + const bool multiFile = LaneSpansMultipleFiles(laneIndex); + for (const SignalRef& ref : m_tab->Lanes()[static_cast(laneIndex)].signals) { - if (signalIndex < 0 || signalIndex >= static_cast(m_document->Signals().size())) - { - continue; - } - const Signal& signal = m_document->Signals()[static_cast(signalIndex)]; - if (!signal.visible) + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || resolved.style == nullptr || resolved.file == nullptr || + !resolved.style->visible) { continue; } - const bool interpolate = (signal.mode != DrawMode::SCATTER); + const bool interpolate = (resolved.style->mode != DrawMode::SCATTER); double value = 0.0; - if (!m_document->SampleAt(signalIndex, m_cursorTime, interpolate, value)) + if (!SampleFileAt(*resolved.file, *resolved.data, cursorTime, interpolate, value)) { continue; } ReadoutLine line; - line.text = signal.name + L" = " + FormatValue(value); - line.color = signal.color; - line.value = ValueToY(value, lane); + line.text = resolved.data->name; + if (multiFile) + { + line.text += L" · " + resolved.file->fileName; + } + line.text += L" = " + FormatValue(value); lines.push_back(std::move(line)); } @@ -1203,7 +1316,8 @@ namespace jdi } 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 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; @@ -1250,53 +1364,64 @@ namespace jdi TextAlign::CENTER); } - // 时间轴列名(过长时自动省略)。 - const std::wstring axisName = - m_document->TimeColumnName().empty() ? std::wstring(L"时间") : m_document->TimeColumnName(); - UiText(g, axisName, m_fonts->monoSmall, + // 时间轴列名(取自首个可见信号所属文件)。 + std::wstring axisName = L"时间"; + if (m_dataSet != nullptr && m_tab != nullptr) + { + for (std::size_t fileIndex = 0; fileIndex < m_dataSet->FileCount(); ++fileIndex) + { + if (!m_tab->FileHasVisible(*m_dataSet, static_cast(fileIndex))) + { + continue; + } + const DataFile* file = m_dataSet->FileAt(static_cast(fileIndex)); + if (file != nullptr && !file->timeColumnName.empty()) + { + axisName = file->timeColumnName; + } + break; + } + } + + UiText(g, UiEllipsize(g, axisName, m_fonts->monoSmall, m_yAxisWidth - kAxisLabelGap * m_scale), + 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) + if (!Tab().CursorVisible() || m_tab == nullptr) { return; } - const float x = TimeToX(m_cursorTime); + const double cursorTime = Tab().CursorTime(); + const float x = TimeToX(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); + UiDrawDashedLine(g, x, m_plotRect.Y, x, m_plotRect.Y + m_plotRect.Height, m_theme->cursor, 1.0f, + Tab().CursorPinned() ? gd::DashStyleSolid : gd::DashStyleDash); // 波形上的取值标记。 for (std::size_t laneIndex = 0; laneIndex < lanes.size(); ++laneIndex) { const LaneVisual& lane = lanes[laneIndex]; - if (lane.empty) + for (const SignalRef& ref : m_tab->Lanes()[laneIndex].signals) { - 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) + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || resolved.style == nullptr || resolved.file == nullptr || + !resolved.style->visible) { continue; } double value = 0.0; - if (!m_document->SampleAt(signalIndex, m_cursorTime, signal.mode != DrawMode::SCATTER, value)) + if (!SampleFileAt(*resolved.file, *resolved.data, cursorTime, + resolved.style->mode != DrawMode::SCATTER, value)) { continue; } @@ -1309,7 +1434,7 @@ namespace jdi const float radius = 3.4f * m_scale; gd::SolidBrush ring(ToGdiColor(m_theme->cardBg)); - gd::SolidBrush fill(ToGdiColor(signal.color)); + gd::SolidBrush fill(ToGdiColor(resolved.style->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); @@ -1317,7 +1442,7 @@ namespace jdi } // 时间轴上的光标标签。 - const std::wstring label = FormatValue(m_cursorTime); + const std::wstring label = FormatValue(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; @@ -1341,7 +1466,7 @@ namespace jdi 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 progress = (maxScroll > 0.0f) ? (Tab().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); diff --git a/Components/SignalPanel/Inc/SignalPanel.hpp b/Components/SignalPanel/Inc/SignalPanel.hpp index 6c56d8c..6e6e4b3 100644 --- a/Components/SignalPanel/Inc/SignalPanel.hpp +++ b/Components/SignalPanel/Inc/SignalPanel.hpp @@ -7,16 +7,20 @@ #include #include +#include +#include "DataModel.hpp" #include "PaintUtil.hpp" -#include "SignalSet.hpp" #include "Theme.hpp" namespace jdi { /** - * @brief 左侧信号面板:信号列表、可见性开关与样式编辑。 + * @brief 左侧信号面板:按文件分组的信号树、可见性勾选与样式编辑。 + * + * 面板只读取数据层(DataSet),全部显示配置读写均落在当前标签页(TabConfig)上, + * 因此不同标签页的信号勾选与样式互不影响。 */ class SignalPanel { @@ -32,10 +36,28 @@ namespace jdi void Setup(HWND host, const UiFonts* fonts, const Theme* theme); /** - * @brief 绑定数据集。 - * @param [IN] document 数据集指针,允许为空。 + * @brief 绑定数据层与当前标签页配置。 + * @param [IN] dataSet 数据集合,允许为空。 + * @param [IN] tab 当前标签页配置,允许为空。 */ - void SetDocument(SignalSet* document); + void SetContext(const DataSet* dataSet, TabConfig* tab); + + /** + * @brief 标记列表结构失效(数据或折叠状态变化后调用)。 + */ + void InvalidateRows() + { + m_rowsDirty = true; + } + + /** + * @brief 判断列表内容是否需要纵向滚动。 + * @return 内容超出可视区域返回 true。 + */ + bool NeedsScrollBar() const + { + return m_contentHeight > m_listRect.Height + 0.5f; + } /** * @brief 设置 DPI 缩放比例。 @@ -88,7 +110,7 @@ namespace jdi bool OnDoubleClick(int x, int y); /** - * @brief 处理鼠标右键按下(弹出信号上下文菜单)。 + * @brief 处理鼠标右键按下(弹出上下文菜单)。 * @param [IN] x 客户区横坐标。 * @param [IN] y 客户区纵坐标。 * @return 需要重绘返回 true。 @@ -116,7 +138,33 @@ namespace jdi */ bool HitTest(int x, int y) const; + /** + * @brief 将列表滚动到指定信号所在位置。 + * @param [IN] ref 目标信号引用。 + */ + void ScrollToSignal(const SignalRef& ref); + private: + /** + * @brief 列表行的类型。 + */ + enum class RowKind : int + { + FILE = 0, /**< 文件分组标题行。 */ + SIGNAL = 1 /**< 信号行。 */ + }; + + /** + * @brief 行的可交互区域。 + */ + enum class RowPart : int + { + NONE = 0, /**< 未命中。 */ + DISCLOSURE, /**< 折叠三角。 */ + CHECK, /**< 复选框。 */ + BODY /**< 行主体。 */ + }; + /** * @brief 样式编辑区中的可点击条目类型。 */ @@ -131,8 +179,34 @@ namespace jdi }; /** - * @brief 计算列表区域内容高度。 - * @return 内容高度。 + * @brief 列表中的一行(文件分组或信号)。 + */ + struct ListRow + { + RowKind kind = RowKind::SIGNAL; /**< 行类型。 */ + int fileIndex = -1; /**< 所属文件下标。 */ + int signalIndex = -1; /**< 信号下标(仅信号行有效)。 */ + float height = 0.0f; /**< 行高(像素)。 */ + }; + + /** + * @brief 重新构建列表行集合。 + */ + void BuildRows(); + + /** + * @brief 在需要时重建列表行集合。 + */ + void EnsureRows(); + + /** + * @brief 依据内容高度重算滚动条可见性与列表可用宽度。 + */ + void RefreshScrollBar(); + + /** + * @brief 计算列表内容总高度。 + * @return 内容高度(像素)。 */ float ContentHeight() const; @@ -142,11 +216,13 @@ namespace jdi void ClampScroll(); /** - * @brief 查询指定坐标对应的信号行下标。 + * @brief 查询坐标命中的行下标与行内区域。 + * @param [IN] x 客户区横坐标。 * @param [IN] y 客户区纵坐标。 + * @param [OUT] part 行内区域类型。 * @return 行下标,未命中返回 -1。 */ - int RowAtY(int y) const; + int HitRow(int x, int y, RowPart& part); /** * @brief 计算行的纵向矩形。 @@ -156,11 +232,18 @@ namespace jdi gd::RectF RowRect(int row) const; /** - * @brief 绘制列表区域的单行。 + * @brief 绘制文件分组标题行。 * @param [IN,OUT] g 绘图上下文。 * @param [IN] row 行下标。 */ - void PaintRow(gd::Graphics& g, int row); + void PaintFileRow(gd::Graphics& g, int row); + + /** + * @brief 绘制信号行。 + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] row 行下标。 + */ + void PaintSignalRow(gd::Graphics& g, int row); /** * @brief 绘制样式编辑区。 @@ -168,13 +251,33 @@ namespace jdi */ void PaintStyleSection(gd::Graphics& g); + /** + * @brief 绘制列表滚动条。 + * @param [IN,OUT] g 绘图上下文。 + */ + void PaintScrollBar(gd::Graphics& g); + + /** + * @brief 计算滚动条滑块矩形。 + * @return 滑块矩形,无需滚动时返回空矩形。 + */ + gd::RectF ScrollThumbRect() const; + /** * @brief 弹出信号右键菜单。 - * @param [IN] signalIndex 目标信号下标。 + * @param [IN] ref 目标信号引用。 * @param [IN] screenX 屏幕坐标横坐标。 * @param [IN] screenY 屏幕坐标纵坐标。 */ - void ShowSignalMenu(int signalIndex, int screenX, int screenY); + void ShowSignalMenu(const SignalRef& ref, int screenX, int screenY); + + /** + * @brief 弹出文件分组右键菜单。 + * @param [IN] fileIndex 目标文件下标。 + * @param [IN] screenX 屏幕坐标横坐标。 + * @param [IN] screenY 屏幕坐标纵坐标。 + */ + void ShowFileMenu(int fileIndex, int screenX, int screenY); /** * @brief 弹出网格分配菜单。 @@ -182,15 +285,22 @@ namespace jdi void ShowLaneMenu(); /** - * @brief 判断全部信号是否均为可见状态。 - * @return 全部可见返回 true。 + * @brief 判断当前标签页中全部信号是否均为可见状态。 + * @return 全部可见且至少有一条信号返回 true。 */ bool AreAllSignalsVisible() const; - HWND m_host = nullptr; /**< 宿主窗口句柄。 */ + /** + * @brief 统计全部信号数量。 + * @return 信号总数。 + */ + int TotalSignalCount() const; + + HWND m_host = nullptr; /**< 宿主窗口句柄。 */ const UiFonts* m_fonts = nullptr; /**< 字体资源。 */ const Theme* m_theme = nullptr; /**< 主题资源。 */ - SignalSet* m_document = nullptr; /**< 数据集。 */ + const DataSet* m_dataSet = nullptr; /**< 数据集合。 */ + TabConfig* m_tab = nullptr; /**< 当前标签页配置。 */ float m_scale = 1.0f; /**< DPI 缩放比例。 */ gd::RectF m_area; /**< 面板整体区域。 */ @@ -199,16 +309,24 @@ namespace jdi gd::RectF m_styleRect; /**< 样式编辑区域。 */ gd::RectF m_toggleAllRect; /**< 全部显示/隐藏按钮。 */ gd::RectF m_laneButtonRect; /**< 网格选择按钮。 */ + gd::RectF m_scrollBarRect; /**< 滚动条区域。 */ 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; /**< 样式行高。 */ + + std::vector m_rows; /**< 列表行集合(惰性缓存)。 */ + bool m_rowsDirty = true; /**< 列表行集合是否需要重建。 */ + float m_contentHeight = 0.0f; /**< 列表内容总高度。 */ + float m_listFullWidth = 0.0f; /**< 列表区不含滚动条的完整宽度。 */ + bool m_scrollBarVisible = false; /**< 是否需要显示滚动条。 */ float m_scrollOffset = 0.0f; /**< 列表滚动偏移量。 */ + bool m_draggingScrollBar = false; /**< 是否正在拖拽滚动条。 */ + int m_scrollGrabOffset = 0; /**< 拖拽时鼠标相对滑块顶部的偏移。 */ int m_hoverRow = -1; /**< 当前悬停行。 */ + RowPart m_hoverPart = RowPart::NONE; /**< 当前悬停的行内区域。 */ StyleHit m_hoverStyle = StyleHit::NONE; /**< 当前悬停的样式条目类型。 */ int m_hoverStyleIndex = -1; /**< 当前悬停的样式条目下标。 */ }; diff --git a/Components/SignalPanel/Src/SignalPanel.cpp b/Components/SignalPanel/Src/SignalPanel.cpp index b14f646..d34c530 100644 --- a/Components/SignalPanel/Src/SignalPanel.cpp +++ b/Components/SignalPanel/Src/SignalPanel.cpp @@ -10,9 +10,10 @@ namespace jdi namespace { - const float kHeaderHeight = 36.0f; /**< 标题区高度。 */ - const float kRowHeight = 30.0f; /**< 列表行高。 */ - const float kStyleHeight = 182.0f; /**< 样式编辑区高度。 */ + const float kHeaderHeight = 38.0f; /**< 标题区高度(逻辑像素)。 */ + const float kFileRowHeight = 30.0f; /**< 文件分组行高。 */ + const float kSignalRowHeight = 27.0f; /**< 信号行高。 */ + const float kStyleHeight = 186.0f; /**< 样式编辑区高度。 */ const float kPadding = 12.0f; /**< 通用内边距。 */ const float kCheckSize = 15.0f; /**< 复选框尺寸。 */ const float kSwatchSize = 12.0f; /**< 颜色小方块尺寸。 */ @@ -20,11 +21,18 @@ namespace jdi const float kBadgeHeight = 17.0f; /**< 网格标签高度。 */ const float kCorner = 4.0f; /**< 通用圆角半径。 */ const float kLineLabelWidth = 40.0f; /**< 样式行标签列宽度。 */ + const float kDisclosureWidth = 16.0f; /**< 折叠三角占位宽度。 */ + const float kIndent = 20.0f; /**< 信号行相对文件行的缩进。 */ + const float kScrollBarThickness = 8.0f; /**< 滚动条宽度。 */ /** @brief 上下文菜单命令 ID 基准值。 */ const int kMenuToggleVisible = 1; const int kMenuSolo = 2; const int kMenuShowAll = 3; + const int kMenuHideAll = 4; + const int kMenuFileShowAll = 10; + const int kMenuFileHideAll = 11; + const int kMenuFileRemove = 12; const int kMenuModeBase = 100; const int kMenuWidthBase = 200; const int kMenuColorBase = 300; @@ -35,7 +43,8 @@ namespace jdi const float kWidthOptions[] = {1.0f, 1.5f, 2.0f, 3.0f}; /** @brief 可选绘制方式顺序。 */ - const DrawMode kModeOptions[] = {DrawMode::LINE, DrawMode::SCATTER, DrawMode::LINE_SCATTER, DrawMode::STEP}; + const DrawMode kModeOptions[] = {DrawMode::LINE, DrawMode::SCATTER, DrawMode::LINE_SCATTER, + DrawMode::STEP}; } SignalPanel::SignalPanel() @@ -52,9 +61,11 @@ namespace jdi m_theme = theme; } - void SignalPanel::SetDocument(SignalSet* document) + void SignalPanel::SetContext(const DataSet* dataSet, TabConfig* tab) { - m_document = document; + m_dataSet = dataSet; + m_tab = tab; + m_rowsDirty = true; m_scrollOffset = 0.0f; } @@ -68,35 +79,141 @@ namespace jdi return UiPointInRect(m_area, static_cast(x), static_cast(y)); } + int SignalPanel::TotalSignalCount() const + { + if (m_dataSet == nullptr) + { + return 0; + } + return static_cast(m_dataSet->SignalCount()); + } + + void SignalPanel::BuildRows() + { + m_rows.clear(); + m_rowsDirty = false; + + if (m_dataSet == nullptr || m_tab == nullptr) + { + m_contentHeight = 0.0f; + return; + } + + const float fileHeight = kFileRowHeight * m_scale; + const float signalHeight = kSignalRowHeight * m_scale; + + const std::vector& files = m_dataSet->Files(); + for (std::size_t fileIndex = 0; fileIndex < files.size(); ++fileIndex) + { + ListRow fileRow; + fileRow.kind = RowKind::FILE; + fileRow.fileIndex = static_cast(fileIndex); + fileRow.height = fileHeight; + m_rows.push_back(fileRow); + + if (m_tab->IsCollapsed(static_cast(fileIndex))) + { + continue; + } + + const std::size_t signalCount = files[fileIndex].signals.size(); + for (std::size_t signalIndex = 0; signalIndex < signalCount; ++signalIndex) + { + ListRow signalRow; + signalRow.kind = RowKind::SIGNAL; + signalRow.fileIndex = static_cast(fileIndex); + signalRow.signalIndex = static_cast(signalIndex); + signalRow.height = signalHeight; + m_rows.push_back(signalRow); + } + } + + m_contentHeight = 0.0f; + for (const ListRow& row : m_rows) + { + m_contentHeight += row.height; + } + } + + void SignalPanel::EnsureRows() + { + if (m_rowsDirty) + { + BuildRows(); + RefreshScrollBar(); + } + } + + void SignalPanel::RefreshScrollBar() + { + if (m_listFullWidth <= 0.0f) + { + m_scrollBarVisible = false; + return; + } + + m_scrollBarVisible = (m_contentHeight > m_listRect.Height + 0.5f); + if (m_scrollBarVisible) + { + const float thickness = kScrollBarThickness * m_scale; + m_listRect.Width = std::max(0.0f, m_listFullWidth - thickness - 2.0f * m_scale); + m_scrollBarRect = gd::RectF(m_area.X + m_area.Width - thickness - 3.0f * m_scale, m_listRect.Y, + thickness, m_listRect.Height); + } + else + { + m_listRect.Width = m_listFullWidth; + m_scrollBarRect = gd::RectF(); + } + + ClampScroll(); + } float SignalPanel::ContentHeight() const { - if (m_document == nullptr) - { - return 0.0f; - } - return static_cast(m_document->Signals().size()) * kRowHeight * m_scale; + return m_contentHeight; } void SignalPanel::ClampScroll() { - const float visibleHeight = m_listRect.Height; - const float contentHeight = ContentHeight(); - const float maxOffset = std::max(0.0f, contentHeight - visibleHeight); + const float maxOffset = std::max(0.0f, m_contentHeight - m_listRect.Height); m_scrollOffset = std::max(0.0f, std::min(m_scrollOffset, maxOffset)); } - int SignalPanel::RowAtY(int y) const + gd::RectF SignalPanel::RowRect(int row) const { - if (m_document == nullptr || m_listRect.Height <= 0.0f) + if (row < 0 || row >= static_cast(m_rows.size())) + { + return gd::RectF(); + } + + float top = m_listRect.Y - m_scrollOffset; + for (int index = 0; index < row; ++index) + { + top += m_rows[static_cast(index)].height; + } + return gd::RectF(m_listRect.X, top, m_listRect.Width, m_rows[static_cast(row)].height); + } + + int SignalPanel::HitRow(int x, int y, RowPart& part) + { + part = RowPart::NONE; + EnsureRows(); + + if (m_listRect.Height <= 0.0f) { return -1; } + const float fx = static_cast(x); const float fy = static_cast(y); if (fy < m_listRect.Y || fy > m_listRect.Y + m_listRect.Height) { return -1; } + if (fx < m_listRect.X || fx > m_listRect.X + m_listRect.Width) + { + return -1; + } const float local = fy - m_listRect.Y + m_scrollOffset; if (local < 0.0f) @@ -104,19 +221,68 @@ namespace jdi return -1; } - const int row = static_cast(local / (kRowHeight * m_scale)); - if (row < 0 || row >= static_cast(m_document->Signals().size())) + float accumulated = 0.0f; + for (std::size_t index = 0; index < m_rows.size(); ++index) { - return -1; + const ListRow& row = m_rows[index]; + if (local >= accumulated && local < accumulated + row.height) + { + const gd::RectF rect = RowRect(static_cast(index)); + const bool isFile = (row.kind == RowKind::FILE); + const float baseX = rect.X + kPadding * m_scale + (isFile ? 0.0f : kIndent * m_scale); + + const gd::RectF disclosureRect(baseX, rect.Y, kDisclosureWidth * m_scale, rect.Height); + const gd::RectF checkRect(baseX + kDisclosureWidth * m_scale, + rect.Y + (rect.Height - kCheckSize * m_scale) * 0.5f, + kCheckSize * m_scale, kCheckSize * m_scale); + + if (isFile && UiPointInRect(disclosureRect, fx, fy)) + { + part = RowPart::DISCLOSURE; + } + else if (UiPointInRect(checkRect, fx, fy)) + { + part = RowPart::CHECK; + } + else + { + part = RowPart::BODY; + } + return static_cast(index); + } + accumulated += row.height; } - return row; + return -1; } - gd::RectF SignalPanel::RowRect(int row) const + void SignalPanel::ScrollToSignal(const SignalRef& ref) { - 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); + EnsureRows(); + if (!ref.IsValid()) + { + return; + } + + float top = 0.0f; + for (std::size_t index = 0; index < m_rows.size(); ++index) + { + const ListRow& row = m_rows[index]; + if (row.kind == RowKind::SIGNAL && row.fileIndex == ref.fileIndex && + row.signalIndex == ref.signalIndex) + { + if (top < m_scrollOffset) + { + m_scrollOffset = top; + } + else if (top + row.height > m_scrollOffset + m_listRect.Height) + { + m_scrollOffset = top + row.height - m_listRect.Height; + } + ClampScroll(); + return; + } + top += row.height; + } } void SignalPanel::Layout(const gd::RectF& area) @@ -127,8 +293,8 @@ namespace jdi 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)); + 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; @@ -137,18 +303,18 @@ namespace jdi m_headerRect.Y + (m_headerRect.Height - toggleHeight) * 0.5f, toggleWidth, toggleHeight); + // 依据内容决定是否显示滚动条,并据此收缩列表可用宽度。 + m_listFullWidth = m_listRect.Width; + EnsureRows(); + RefreshScrollBar(); + // 样式编辑区布局。 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; + const float contentLeft = m_styleRect.X + kPadding * m_scale + labelWidth; int paletteCount = 0; - const std::uint32_t* palette = SignalPalette(paletteCount); - (void)palette; - + SignalPalette(paletteCount); m_colorCount = std::min(paletteCount, static_cast(m_colorRects.size())); // 色块尺寸按可用宽度自适应,避免超出面板。 @@ -173,8 +339,7 @@ namespace jdi 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); + 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); @@ -182,7 +347,7 @@ namespace jdi 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 modeRowTop = widthRowTop + 36.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) @@ -192,11 +357,9 @@ namespace jdi 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(); + const float laneRowTop = modeRowTop + 40.0f * m_scale; + m_laneButtonRect = + gd::RectF(contentLeft, laneRowTop, std::min(contentWidth, 150.0f * m_scale), 26.0f * m_scale); } void SignalPanel::Paint(gd::Graphics& g) @@ -206,18 +369,24 @@ namespace jdi return; } + EnsureRows(); 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()))); + std::wstring title = L"信号"; + if (m_dataSet != nullptr && m_tab != nullptr) + { + title += L" · " + std::to_wstring(m_tab->VisibleCount()) + L"/" + + std::to_wstring(TotalSignalCount()) + L" 已选"; + } 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); + m_headerRect.Width - kPadding * 2.0f * m_scale - m_toggleAllRect.Width - + 8.0f * m_scale, + m_headerRect.Height); + UiText(g, UiEllipsize(g, title, m_fonts->uiBold, titleRect.Width), m_fonts->uiBold, titleRect, + m_theme->textStrong, TextAlign::LEFT); const bool allVisible = AreAllSignalsVisible(); const std::wstring toggleLabel = allVisible ? L"全部隐藏" : L"全部显示"; @@ -227,8 +396,9 @@ namespace jdi UiFillRoundRect(g, m_toggleAllRect, kCorner * m_scale, m_theme->hover); } UiDrawRoundRect(g, m_toggleAllRect, kCorner * m_scale, m_theme->border, 1.0f); + const bool enabled = (m_dataSet != nullptr && !m_dataSet->Empty()); UiText(g, toggleLabel, m_fonts->uiSmall, m_toggleAllRect, - (m_document == nullptr) ? m_theme->textMuted : m_theme->text, TextAlign::CENTER); + enabled ? m_theme->text : m_theme->textMuted, TextAlign::CENTER); } // 列表区。 @@ -236,14 +406,14 @@ namespace jdi 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()) + if (m_dataSet == nullptr || m_dataSet->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()); + const int rowCount = static_cast(m_rows.size()); for (int row = 0; row < rowCount; ++row) { const gd::RectF rect = RowRect(row); @@ -251,20 +421,29 @@ namespace jdi { continue; } - PaintRow(g, row); + if (m_rows[static_cast(row)].kind == RowKind::FILE) + { + PaintFileRow(g, row); + } + else + { + PaintSignalRow(g, row); + } } } g.Restore(saveState); } + PaintScrollBar(g); 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); + 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, @@ -272,40 +451,140 @@ namespace jdi } } - void SignalPanel::PaintRow(gd::Graphics& g, int row) + void SignalPanel::PaintFileRow(gd::Graphics& g, int row) { - if (m_document == nullptr || row < 0 || row >= static_cast(m_document->Signals().size())) + if (m_dataSet == nullptr || m_tab == nullptr || row < 0 || row >= static_cast(m_rows.size())) + { + return; + } + + const ListRow& item = m_rows[static_cast(row)]; + const DataFile* file = m_dataSet->FileAt(item.fileIndex); + if (file == nullptr) { return; } - const Signal& signal = m_document->Signals()[static_cast(row)]; const gd::RectF rect = RowRect(row); - const bool selected = (m_document->SelectedSignal() == row); + const bool hovered = (m_hoverRow == row); + if (hovered) + { + UiFillRect(g, rect, m_theme->hover); + } + UiFillRect(g, gd::RectF(rect.X, rect.Y, 2.0f * m_scale, rect.Height), + WithAlpha(m_theme->borderStrong, 0x90)); + + const float baseX = rect.X + kPadding * m_scale; + const float centerY = rect.Y + rect.Height * 0.5f; + const bool collapsed = m_tab->IsCollapsed(item.fileIndex); + + // 折叠三角。 + { + const float cx = baseX + kDisclosureWidth * m_scale * 0.5f; + const float arm = 3.0f * m_scale; + const std::uint32_t color = + (hovered && m_hoverPart == RowPart::DISCLOSURE) ? m_theme->accent : m_theme->text; + if (collapsed) + { + UiDrawLine(g, cx - arm * 0.5f, centerY - arm, cx + arm * 0.7f, centerY, color, 1.5f * m_scale); + UiDrawLine(g, cx + arm * 0.7f, centerY, cx - arm * 0.5f, centerY + arm, color, 1.5f * m_scale); + } + else + { + UiDrawLine(g, cx - arm, centerY - arm * 0.5f, cx, centerY + arm * 0.7f, color, 1.5f * m_scale); + UiDrawLine(g, cx, centerY + arm * 0.7f, cx + arm, centerY - arm * 0.5f, color, 1.5f * m_scale); + } + } + + // 分组复选框(三态:全选 / 部分 / 未选)。 + const gd::RectF checkRect(baseX + kDisclosureWidth * m_scale, centerY - kCheckSize * m_scale * 0.5f, + kCheckSize * m_scale, kCheckSize * m_scale); + const bool allVisible = m_tab->FileAllVisible(*m_dataSet, item.fileIndex); + const bool anyVisible = m_tab->FileHasVisible(*m_dataSet, item.fileIndex); + + if (allVisible) + { + 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 if (anyVisible) + { + UiFillRoundRect(g, checkRect, 3.0f * m_scale, m_theme->accentSoft); + UiDrawRoundRect(g, checkRect, 3.0f * m_scale, m_theme->accent, 1.0f); + UiFillRect(g, gd::RectF(checkRect.X + 3.0f * m_scale, centerY - 1.0f * m_scale, + checkRect.Width - 6.0f * m_scale, 2.0f * m_scale), + m_theme->accent); + } + else + { + UiFillRoundRect(g, checkRect, 3.0f * m_scale, m_theme->cardBg); + UiDrawRoundRect(g, checkRect, 3.0f * m_scale, m_theme->borderStrong, 1.0f); + } + + // 文件名称与统计。 + const float nameLeft = checkRect.X + checkRect.Width + 8.0f * m_scale; + const std::wstring stats = L" " + std::to_wstring(file->signals.size()) + L" 项"; + const float statsWidth = UiMeasureTextWidth(m_fonts->uiSmall, stats); + const float nameWidth = std::max(20.0f, rect.X + rect.Width - kPadding * m_scale - statsWidth - nameLeft); + + UiText(g, UiEllipsize(g, file->fileName, m_fonts->uiBold, nameWidth), m_fonts->uiBold, + gd::RectF(nameLeft, rect.Y, nameWidth, rect.Height), m_theme->textStrong, TextAlign::LEFT); + UiText(g, stats, m_fonts->uiSmall, + gd::RectF(rect.X + rect.Width - kPadding * m_scale - statsWidth, rect.Y, statsWidth, + rect.Height), + m_theme->textMuted, TextAlign::RIGHT); + } + + void SignalPanel::PaintSignalRow(gd::Graphics& g, int row) + { + if (m_dataSet == nullptr || m_tab == nullptr || row < 0 || row >= static_cast(m_rows.size())) + { + return; + } + + const ListRow& item = m_rows[static_cast(row)]; + const SignalRef ref{item.fileIndex, item.signalIndex}; + const SignalData* signal = m_dataSet->Resolve(ref); + const SignalStyle* style = m_tab->Style(ref); + if (signal == nullptr || style == nullptr) + { + return; + } + + const gd::RectF rect = RowRect(row); + const bool selected = (m_tab->Selected() == ref); + const bool hovered = (m_hoverRow == 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) + else if (hovered) { UiFillRect(g, rect, m_theme->hover); } + const float baseX = rect.X + kPadding * m_scale + kIndent * m_scale; const float centerY = rect.Y + rect.Height * 0.5f; // 复选框。 - const gd::RectF checkRect(rect.X + kPadding * m_scale, centerY - kCheckSize * m_scale * 0.5f, + const gd::RectF checkRect(baseX + kDisclosureWidth * m_scale, centerY - kCheckSize * m_scale * 0.5f, kCheckSize * m_scale, kCheckSize * m_scale); - if (signal.visible) + if (style->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 + 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); } @@ -316,22 +595,34 @@ namespace jdi } // 颜色块。 - const gd::RectF swatchRect(checkRect.X + checkRect.Width + 10.0f * m_scale, + const gd::RectF swatchRect(checkRect.X + checkRect.Width + 8.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)); + style->visible ? style->color : WithAlpha(style->color, 0x55)); // 名称。 + const float unitWidth = signal->unit.empty() + ? 0.0f + : UiMeasureTextWidth(m_fonts->uiSmall, L" " + signal->unit) + 4.0f * m_scale; 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 float nameLeft = swatchRect.X + swatchRect.Width + 8.0f * m_scale; + const float nameWidth = rect.X + rect.Width - kPadding * m_scale - badgeOffset - unitWidth - nameLeft; + const std::uint32_t nameColor = style->visible ? m_theme->text : m_theme->textMuted; + UiText(g, UiEllipsize(g, signal->name, m_fonts->ui, std::max(16.0f, nameWidth)), m_fonts->ui, + gd::RectF(nameLeft, rect.Y, std::max(16.0f, nameWidth), rect.Height), nameColor, + TextAlign::LEFT); + + if (!signal->unit.empty()) + { + const std::wstring unitText = L" " + signal->unit; + UiText(g, unitText, m_fonts->uiSmall, + gd::RectF(nameLeft + std::max(16.0f, nameWidth), rect.Y, unitWidth, rect.Height), + m_theme->textMuted, TextAlign::LEFT); + } // 网格标记。 - const int laneIndex = m_document->LaneOfSignal(row); + const int laneIndex = m_tab->LaneOfSignal(ref); if (laneIndex >= 0) { const gd::RectF badgeRect(rect.X + rect.Width - kPadding * m_scale - kBadgeWidth * m_scale, @@ -346,6 +637,34 @@ namespace jdi } } + gd::RectF SignalPanel::ScrollThumbRect() const + { + if (!m_scrollBarVisible || m_contentHeight <= m_listRect.Height || m_listRect.Height <= 0.0f) + { + return gd::RectF(); + } + + const float ratio = std::min(1.0f, m_listRect.Height / m_contentHeight); + const float thumbHeight = std::max(24.0f * m_scale, m_scrollBarRect.Height * ratio); + const float maxScroll = m_contentHeight - m_listRect.Height; + const float progress = (maxScroll > 0.0f) ? (m_scrollOffset / maxScroll) : 0.0f; + const float thumbY = m_scrollBarRect.Y + progress * (m_scrollBarRect.Height - thumbHeight); + + return gd::RectF(m_scrollBarRect.X, thumbY, m_scrollBarRect.Width, thumbHeight); + } + + void SignalPanel::PaintScrollBar(gd::Graphics& g) + { + if (!m_scrollBarVisible || m_contentHeight <= m_listRect.Height) + { + return; + } + + UiFillRoundRect(g, m_scrollBarRect, kScrollBarThickness * m_scale * 0.5f, m_theme->gridWeak); + UiFillRoundRect(g, ScrollThumbRect(), kScrollBarThickness * m_scale * 0.5f, m_theme->borderStrong); + } + + void SignalPanel::PaintStyleSection(gd::Graphics& g) { if (m_styleRect.Height <= 0.0f) @@ -355,20 +674,28 @@ namespace jdi 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())) + const SignalStyle* style = (m_tab == nullptr) ? nullptr : m_tab->Style(m_tab->Selected()); + const SignalData* signal = (m_dataSet == nullptr || m_tab == nullptr) + ? nullptr + : m_dataSet->Resolve(m_tab->Selected()); + if (style == nullptr || signal == nullptr) { - UiText(g, L"请选择信号以编辑样式", m_fonts->uiSmall, m_styleRect, m_theme->textMuted, TextAlign::CENTER); + UiText(g, L"请选择信号以编辑样式", m_fonts->uiSmall, m_styleRect, m_theme->textMuted, + TextAlign::CENTER); return; } - const Signal& signal = m_document->Signals()[static_cast(selectedIndex)]; + // 标题行(带上来源文件,便于多文件对比时区分同名信号)。 + std::wstring styleTitle = L"样式 · " + signal->name; + if (const DataFile* file = m_dataSet->FileAt(m_tab->Selected().fileIndex); file != nullptr) + { + styleTitle += L" · " + file->fileName; + } - // 标题行。 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); + UiText(g, UiEllipsize(g, styleTitle, m_fonts->uiBold, titleRect.Width), m_fonts->uiBold, titleRect, + m_theme->textStrong, TextAlign::LEFT); const float labelX = m_styleRect.X + kPadding * m_scale; const float labelWidth = kLineLabelWidth * m_scale; @@ -376,8 +703,8 @@ namespace jdi // 颜色。 { 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); + 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); @@ -386,14 +713,15 @@ namespace jdi 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); + const bool active = (style->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, + UiDrawRoundRect(g, ring, 5.0f * m_scale, + active ? m_theme->textStrong : m_theme->borderStrong, active ? 1.6f : 1.0f); } } @@ -408,18 +736,16 @@ namespace jdi 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 active = std::fabs(style->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); + active ? m_theme->accent : m_theme->text, kWidthOptions[index] * m_scale); } } @@ -432,7 +758,7 @@ namespace jdi 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 active = (style->mode == kModeOptions[index]); const bool hovered = (m_hoverStyle == StyleHit::MODE && m_hoverStyleIndex == index); UiFillRoundRect(g, rect, kCorner * m_scale, @@ -446,21 +772,22 @@ namespace jdi // 所属网格。 { UiText(g, L"网格", m_fonts->uiSmall, - gd::RectF(labelX, m_laneButtonRect.Y, labelWidth, m_laneButtonRect.Height), m_theme->textMuted, - TextAlign::LEFT); + 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 int laneIndex = m_tab->LaneOfSignal(m_tab->Selected()); + const 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); + UiText(g, UiEllipsize(g, label, m_fonts->uiSmall, textRect.Width), 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]; @@ -474,23 +801,32 @@ namespace jdi bool SignalPanel::AreAllSignalsVisible() const { - if (m_document == nullptr || m_document->Signals().empty()) + if (m_dataSet == nullptr || m_tab == nullptr || m_dataSet->Empty()) { return false; } - for (const Signal& signal : m_document->Signals()) - { - if (!signal.visible) - { - return false; - } - } - return true; + return m_tab->VisibleCount() == TotalSignalCount(); } bool SignalPanel::OnMouseMove(int x, int y) { - const int hoverRow = RowAtY(y); + if (m_draggingScrollBar) + { + const gd::RectF thumb = ScrollThumbRect(); + const float travel = static_cast(y - m_scrollGrabOffset) - m_scrollBarRect.Y; + const float usable = m_scrollBarRect.Height - thumb.Height; + if (usable > 0.0f) + { + const float progress = std::max(0.0f, std::min(1.0f, travel / usable)); + m_scrollOffset = progress * (m_contentHeight - m_listRect.Height); + ClampScroll(); + } + return true; + } + + RowPart part = RowPart::NONE; + const int hoverRow = HitRow(x, y, part); + StyleHit hoverStyle = StyleHit::NONE; int hoverStyleIndex = -1; @@ -501,7 +837,7 @@ namespace jdi { hoverStyle = StyleHit::TOGGLE_ALL; } - else if (m_document != nullptr && m_document->SelectedSignal() >= 0) + else if (m_tab != nullptr && m_tab->Style(m_tab->Selected()) != nullptr) { for (int index = 0; index < m_colorCount; ++index) { @@ -542,12 +878,14 @@ namespace jdi } } - if (hoverRow == m_hoverRow && hoverStyle == m_hoverStyle && hoverStyleIndex == m_hoverStyleIndex) + if (hoverRow == m_hoverRow && hoverStyle == m_hoverStyle && hoverStyleIndex == m_hoverStyleIndex && + part == m_hoverPart) { return false; } m_hoverRow = hoverRow; + m_hoverPart = part; m_hoverStyle = hoverStyle; m_hoverStyleIndex = hoverStyleIndex; return true; @@ -555,7 +893,7 @@ namespace jdi bool SignalPanel::OnLButtonDown(int x, int y) { - if (m_document == nullptr) + if (m_dataSet == nullptr || m_tab == nullptr) { return false; } @@ -563,26 +901,49 @@ namespace jdi const float fx = static_cast(x); const float fy = static_cast(y); + // 滚动条拖拽优先于其它命中测试。 + if (m_scrollBarVisible && UiPointInRect(m_scrollBarRect, fx, fy)) + { + const gd::RectF thumb = ScrollThumbRect(); + m_draggingScrollBar = true; + if (UiPointInRect(thumb, fx, fy)) + { + m_scrollGrabOffset = y - static_cast(thumb.Y); + } + else + { + // 点击空白区域时滑块中心对齐鼠标位置。 + m_scrollGrabOffset = static_cast(thumb.Height * 0.5f); + const float travel = fy - m_scrollBarRect.Y - static_cast(m_scrollGrabOffset); + const float usable = m_scrollBarRect.Height - thumb.Height; + if (usable > 0.0f) + { + const float progress = std::max(0.0f, std::min(1.0f, travel / usable)); + m_scrollOffset = progress * (m_contentHeight - m_listRect.Height); + ClampScroll(); + } + } + return true; + } + // 全部显示/隐藏。 if (UiPointInRect(m_toggleAllRect, fx, fy)) { - m_document->SetAllVisible(!AreAllSignalsVisible()); + m_tab->SetAllVisible(*m_dataSet, !AreAllSignalsVisible()); + m_rowsDirty = true; return true; } // 样式编辑区。 - const int selectedIndex = m_document->SelectedSignal(); - if (selectedIndex >= 0 && selectedIndex < static_cast(m_document->Signals().size())) + if (SignalStyle* style = m_tab->Style(m_tab->Selected()); style != nullptr) { - 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]; + style->color = palette[index % paletteCount]; return true; } } @@ -590,7 +951,7 @@ namespace jdi { if (UiPointInRect(m_widthRects[static_cast(index)], fx, fy)) { - signal.lineWidth = kWidthOptions[index]; + style->lineWidth = kWidthOptions[index]; return true; } } @@ -598,11 +959,11 @@ namespace jdi { if (UiPointInRect(m_modeRects[static_cast(index)], fx, fy)) { - signal.mode = kModeOptions[index]; + style->mode = kModeOptions[index]; return true; } } - if (UiPointInRect(m_laneButtonRect, fx, fy)) + if (UiPointInRect(m_laneButtonRect, fx, fy) && m_tab->Selected().IsValid()) { ShowLaneMenu(); return true; @@ -610,78 +971,118 @@ namespace jdi } // 列表行。 - const int row = RowAtY(y); - if (row >= 0) + RowPart part = RowPart::NONE; + const int row = HitRow(x, y, part); + 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); + return false; + } - if (UiPointInRect(checkRect, fx, fy)) + const ListRow& item = m_rows[static_cast(row)]; + + if (item.kind == RowKind::FILE) + { + if (part == RowPart::CHECK) { - Signal& signal = m_document->Signals()[static_cast(row)]; - signal.visible = !signal.visible; - m_document->SetSelectedSignal(row); + // 分组勾选:全选则清空,否则全选。 + const bool allVisible = m_tab->FileAllVisible(*m_dataSet, item.fileIndex); + m_tab->SetFileVisible(*m_dataSet, item.fileIndex, !allVisible); + m_rowsDirty = true; return true; } + // 折叠三角与行主体均视为切换折叠。 + m_tab->ToggleCollapsed(item.fileIndex); + m_rowsDirty = true; + return true; + } - m_document->SetSelectedSignal(row); + const SignalRef ref{item.fileIndex, item.signalIndex}; + m_tab->SetSelected(ref); + + if (part == RowPart::CHECK) + { + // 勾选/取消勾选会立即触发网格重新划分(取消后空网格随之消失)。 + m_tab->ToggleVisible(*m_dataSet, ref); + m_rowsDirty = true; + } + return true; + } + + bool SignalPanel::OnLButtonUp(int /*x*/, int /*y*/) + { + if (m_draggingScrollBar) + { + m_draggingScrollBar = false; return true; } return false; } - bool SignalPanel::OnLButtonUp(int /*x*/, int /*y*/) - { - return false; - } - bool SignalPanel::OnDoubleClick(int x, int y) { - if (m_document == nullptr) + if (m_dataSet == nullptr || m_tab == nullptr) { return false; } - const int row = RowAtY(y); + RowPart part = RowPart::NONE; + const int row = HitRow(x, y, part); if (row < 0) { return false; } - Signal& signal = m_document->Signals()[static_cast(row)]; - signal.visible = !signal.visible; - m_document->SetSelectedSignal(row); + const ListRow& item = m_rows[static_cast(row)]; + if (item.kind == RowKind::FILE) + { + m_tab->ToggleCollapsed(item.fileIndex); + m_rowsDirty = true; + return true; + } + + const SignalRef ref{item.fileIndex, item.signalIndex}; + m_tab->SetSelected(ref); + m_tab->ToggleVisible(*m_dataSet, ref); + m_rowsDirty = true; return true; } bool SignalPanel::OnRButtonDown(int x, int y) { - if (m_document == nullptr) + if (m_dataSet == nullptr || m_tab == nullptr) { return false; } - const int row = RowAtY(y); + RowPart part = RowPart::NONE; + const int row = HitRow(x, y, part); 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); + + const ListRow& item = m_rows[static_cast(row)]; + if (item.kind == RowKind::FILE) + { + ShowFileMenu(item.fileIndex, cursor.x, cursor.y); + return true; + } + + const SignalRef ref{item.fileIndex, item.signalIndex}; + m_tab->SetSelected(ref); + ShowSignalMenu(ref, cursor.x, cursor.y); return true; } bool SignalPanel::OnMouseWheel(int delta) { - if (m_document == nullptr) + if (m_dataSet == nullptr) { return false; } @@ -699,6 +1100,7 @@ namespace jdi return false; } m_hoverRow = -1; + m_hoverPart = RowPart::NONE; m_hoverStyle = StyleHit::NONE; m_hoverStyleIndex = -1; return true; @@ -706,16 +1108,13 @@ namespace jdi void SignalPanel::ShowLaneMenu() { - if (m_document == nullptr || m_host == nullptr) + if (m_dataSet == nullptr || m_tab == nullptr || m_host == nullptr || !m_tab->Selected().IsValid()) { return; } - const int selectedIndex = m_document->SelectedSignal(); - if (selectedIndex < 0) - { - return; - } + const SignalRef ref = m_tab->Selected(); + const int currentLane = m_tab->LaneOfSignal(ref); HMENU menu = CreatePopupMenu(); if (menu == nullptr) @@ -723,15 +1122,17 @@ namespace jdi return; } - const int currentLane = m_document->LaneOfSignal(selectedIndex); - const std::vector& lanes = m_document->Lanes(); + const std::vector& lanes = m_tab->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" 条信号)"; + const std::wstring label = L"网格 " + std::to_wstring(index + 1) + L"(" + + std::to_wstring(lanes[index].signals.size()) + L" 条信号)"; AppendMenuW(menu, MF_STRING, static_cast(kMenuLaneBase + index), label.c_str()); } - AppendMenuW(menu, MF_SEPARATOR, 0, nullptr); + if (!lanes.empty()) + { + AppendMenuW(menu, MF_SEPARATOR, 0, nullptr); + } AppendMenuW(menu, MF_STRING, static_cast(kMenuLaneNew), L"新建网格"); if (currentLane >= 0 && currentLane < static_cast(lanes.size())) @@ -746,31 +1147,33 @@ namespace jdi 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); + anchor.y + static_cast(m_laneButtonRect.Height), 0, m_host, + nullptr); DestroyMenu(menu); if (command >= kMenuLaneBase && command < kMenuLaneNew) { - m_document->MoveSignalToLane(selectedIndex, command - kMenuLaneBase); + m_tab->MoveSignalToLane(*m_dataSet, ref, command - kMenuLaneBase); } else if (command == kMenuLaneNew) { - m_document->MoveSignalToLane(selectedIndex, -1); + m_tab->MoveSignalToLane(*m_dataSet, ref, -1); } } - void SignalPanel::ShowSignalMenu(int signalIndex, int screenX, int screenY) + void SignalPanel::ShowSignalMenu(const SignalRef& ref, int screenX, int screenY) { - if (m_document == nullptr || m_host == nullptr) - { - return; - } - if (signalIndex < 0 || signalIndex >= static_cast(m_document->Signals().size())) + if (m_dataSet == nullptr || m_tab == nullptr || m_host == nullptr) { return; } - Signal& signal = m_document->Signals()[static_cast(signalIndex)]; + SignalStyle* style = m_tab->Style(ref); + const SignalData* signal = m_dataSet->Resolve(ref); + if (style == nullptr || signal == nullptr) + { + return; + } HMENU menu = CreatePopupMenu(); if (menu == nullptr) @@ -779,21 +1182,23 @@ namespace jdi } AppendMenuW(menu, MF_STRING, static_cast(kMenuToggleVisible), - signal.visible ? L"隐藏该信号" : L"显示该信号"); + style->visible ? L"隐藏该信号" : L"显示该信号"); AppendMenuW(menu, MF_STRING, static_cast(kMenuSolo), L"仅显示该信号"); - AppendMenuW(menu, MF_STRING, static_cast(kMenuShowAll), L"显示全部信号"); + AppendMenuW(menu, MF_STRING, static_cast(kMenuHideAll), 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"灰"}; + 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)); + const 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) + if (style->color == color) { CheckMenuRadioItem(colorMenu, static_cast(kMenuColorBase), static_cast(kMenuColorBase + SignalColorCount() - 1), @@ -807,7 +1212,7 @@ namespace jdi { 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) + if (std::fabs(style->lineWidth - kWidthOptions[index]) < 0.01f) { CheckMenuRadioItem(widthMenu, static_cast(kMenuWidthBase), static_cast(kMenuWidthBase + 3), @@ -821,23 +1226,27 @@ namespace jdi { AppendMenuW(modeMenu, MF_STRING, static_cast(kMenuModeBase + index), DrawModeName(kModeOptions[index])); - if (signal.mode == kModeOptions[index]) + if (style->mode == kModeOptions[index]) { - CheckMenuRadioItem(modeMenu, static_cast(kMenuModeBase), static_cast(kMenuModeBase + 3), + 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); + const std::vector& lanes = m_tab->Lanes(); + const int currentLane = m_tab->LaneOfSignal(ref); 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); + if (!lanes.empty()) + { + AppendMenuW(laneMenu, MF_SEPARATOR, 0, nullptr); + } AppendMenuW(laneMenu, MF_STRING, static_cast(kMenuLaneNew), L"新建网格"); if (currentLane >= 0 && !lanes.empty()) { @@ -848,41 +1257,95 @@ namespace jdi 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); + 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; + m_tab->ToggleVisible(*m_dataSet, ref); + m_rowsDirty = true; } else if (command == kMenuSolo) { - m_document->SoloSignal(signalIndex); + m_tab->SoloSignal(*m_dataSet, ref); + m_rowsDirty = true; } - else if (command == kMenuShowAll) + else if (command == kMenuHideAll) { - m_document->SetAllVisible(true); + m_tab->SetAllVisible(*m_dataSet, false); + m_rowsDirty = true; } else if (command >= kMenuModeBase && command < kMenuModeBase + 4) { - signal.mode = kModeOptions[command - kMenuModeBase]; + style->mode = kModeOptions[command - kMenuModeBase]; } else if (command >= kMenuWidthBase && command < kMenuWidthBase + 4) { - signal.lineWidth = kWidthOptions[command - kMenuWidthBase]; + style->lineWidth = kWidthOptions[command - kMenuWidthBase]; } else if (command >= kMenuColorBase && command < kMenuColorBase + SignalColorCount()) { - signal.color = palette[(command - kMenuColorBase) % paletteCount]; + style->color = palette[(command - kMenuColorBase) % paletteCount]; } else if (command >= kMenuLaneBase && command < kMenuLaneNew) { - m_document->MoveSignalToLane(signalIndex, command - kMenuLaneBase); + m_tab->MoveSignalToLane(*m_dataSet, ref, command - kMenuLaneBase); + m_rowsDirty = true; } else if (command == kMenuLaneNew) { - m_document->MoveSignalToLane(signalIndex, -1); + m_tab->MoveSignalToLane(*m_dataSet, ref, -1); + m_rowsDirty = true; + } + } + + void SignalPanel::ShowFileMenu(int fileIndex, int screenX, int screenY) + { + if (m_dataSet == nullptr || m_tab == nullptr || m_host == nullptr) + { + return; + } + + const DataFile* file = m_dataSet->FileAt(fileIndex); + if (file == nullptr) + { + return; + } + + HMENU menu = CreatePopupMenu(); + if (menu == nullptr) + { + return; + } + + AppendMenuW(menu, MF_STRING, static_cast(kMenuFileShowAll), L"选中该文件全部信号"); + AppendMenuW(menu, MF_STRING, static_cast(kMenuFileHideAll), L"取消该文件全部信号"); + AppendMenuW(menu, MF_SEPARATOR, 0, nullptr); + AppendMenuW(menu, MF_STRING, static_cast(kMenuFileRemove), 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 == kMenuFileShowAll) + { + m_tab->SetFileVisible(*m_dataSet, fileIndex, true); + m_rowsDirty = true; + } + else if (command == kMenuFileHideAll) + { + m_tab->SetFileVisible(*m_dataSet, fileIndex, false); + m_rowsDirty = true; + } + else if (command == kMenuFileRemove) + { + // 移除文件属于数据层操作,交由宿主窗口处理。 + if (m_host != nullptr) + { + PostMessageW(m_host, WM_APP + 1, static_cast(fileIndex), 0); + } } } diff --git a/Components/TabBar/Inc/TabBar.hpp b/Components/TabBar/Inc/TabBar.hpp new file mode 100644 index 0000000..9ee2488 --- /dev/null +++ b/Components/TabBar/Inc/TabBar.hpp @@ -0,0 +1,179 @@ +#ifndef __TAB_BAR_HPP__ +#define __TAB_BAR_HPP__ + +#include +#include +#include + +#include +#include + +#include "PaintUtil.hpp" +#include "Theme.hpp" + +namespace jdi +{ + + /** + * @brief 标签栏交互结果。 + */ + struct TabBarResult + { + /** + * @brief 交互动作类型。 + */ + enum class Type : int + { + NONE = 0, /**< 未触发任何动作。 */ + SELECT, /**< 切换到指定标签页。 */ + CLOSE, /**< 关闭指定标签页。 */ + ADD, /**< 新建标签页。 */ + CONTEXT /**< 请求弹出标签页上下文菜单。 */ + }; + + Type type = Type::NONE; /**< 动作类型。 */ + int index = -1; /**< 标签页下标(ADD 时无意义)。 */ + int screenX = 0; /**< 上下文菜单请求的屏幕横坐标。 */ + int screenY = 0; /**< 上下文菜单请求的屏幕纵坐标。 */ + }; + + /** + * @brief 顶部标签栏:支持多视图切换、新建与关闭。 + */ + class TabBar + { + public: + TabBar(); + + /** + * @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] names 标签页名称列表。 + */ + void SetTabs(const std::vector& names); + + /** + * @brief 设置当前激活的标签页下标。 + * @param [IN] index 标签页下标,-1 表示无激活页。 + */ + void SetActiveIndex(int index); + + /** + * @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 客户区纵坐标。 + * @return 命中可点击元素返回 true。 + */ + bool OnLButtonDown(int x, int y); + + /** + * @brief 处理鼠标左键抬起并给出交互结果。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @param [OUT] result 交互结果。 + * @return 视觉状态发生变化返回 true。 + */ + bool OnLButtonUp(int x, int y, TabBarResult& result); + + /** + * @brief 处理鼠标右键按下(请求标签页上下文菜单)。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @param [IN] screenX 屏幕横坐标。 + * @param [IN] screenY 屏幕纵坐标。 + * @param [OUT] result 交互结果。 + * @return 命中标签页返回 true。 + */ + bool OnRButtonDown(int x, int y, int screenX, int screenY, TabBarResult& result); + + /** + * @brief 处理鼠标离开标签栏。 + * @return 视觉状态发生变化返回 true。 + */ + bool OnMouseLeave(); + + /** + * @brief 判断坐标是否落在标签栏区域内。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @return 落在区域内返回 true。 + */ + bool HitTest(int x, int y) const; + + private: + /** + * @brief 单个标签页的布局信息。 + */ + struct TabItem + { + std::wstring name; /**< 标签页名称。 */ + gd::RectF rect; /**< 标签整体矩形。 */ + gd::RectF closeRect; /**< 关闭按钮矩形。 */ + }; + + /** + * @brief 判断坐标命中的标签页下标。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @return 标签页下标,未命中返回 -1。 + */ + int TabAtPoint(int x, int y) const; + + const UiFonts* m_fonts = nullptr; /**< 字体资源。 */ + const Theme* m_theme = nullptr; /**< 主题资源。 */ + float m_scale = 1.0f; /**< DPI 缩放比例。 */ + + std::vector m_tabs; /**< 标签页布局集合。 */ + gd::RectF m_addRect; /**< 新建按钮矩形。 */ + gd::RectF m_area; /**< 标签栏整体区域。 */ + + int m_activeIndex = -1; /**< 当前激活标签页下标。 */ + int m_hoverIndex = -1; /**< 当前悬停标签页下标。 */ + bool m_hoverClose = false; /**< 是否悬停在关闭按钮上。 */ + bool m_hoverAdd = false; /**< 是否悬停在新建按钮上。 */ + int m_pressedIndex = -1; /**< 当前按下的标签页下标。 */ + bool m_pressedClose = false; /**< 是否按在关闭按钮上。 */ + bool m_pressedAdd = false; /**< 是否按在新建按钮上。 */ + }; + +} // namespace jdi + +#endif // __TAB_BAR_HPP__ diff --git a/Components/TabBar/Src/TabBar.cpp b/Components/TabBar/Src/TabBar.cpp new file mode 100644 index 0000000..97c30d5 --- /dev/null +++ b/Components/TabBar/Src/TabBar.cpp @@ -0,0 +1,307 @@ +#include "TabBar.hpp" + +#include +#include + +namespace jdi +{ + + namespace + { + const float kTabHeight = 32.0f; /**< 标签高度(逻辑像素)。 */ + const float kTopMargin = 4.0f; /**< 标签顶部留白。 */ + const float kLeftPadding = 8.0f; /**< 左侧留白。 */ + const float kTabPadding = 14.0f; /**< 标签文本左右内边距。 */ + const float kCloseSize = 16.0f; /**< 关闭按钮尺寸。 */ + const float kCloseGap = 6.0f; /**< 文本与关闭按钮间距。 */ + const float kAddSize = 24.0f; /**< 新建按钮尺寸。 */ + const float kAddGap = 6.0f; /**< 新建按钮与末个标签的间距。 */ + const float kCorner = 5.0f; /**< 标签圆角半径。 */ + const float kMaxTabWidth = 200.0f; /**< 单个标签最大宽度(逻辑像素)。 */ + } + + TabBar::TabBar() = default; + + void TabBar::Setup(const UiFonts* fonts, const Theme* theme) + { + m_fonts = fonts; + m_theme = theme; + } + + void TabBar::SetScale(float scale) + { + m_scale = (scale > 0.1f) ? scale : 1.0f; + } + + void TabBar::SetTabs(const std::vector& names) + { + m_tabs.clear(); + m_tabs.resize(names.size()); + for (std::size_t index = 0; index < names.size(); ++index) + { + m_tabs[index].name = names[index]; + } + if (m_activeIndex >= static_cast(m_tabs.size())) + { + m_activeIndex = m_tabs.empty() ? -1 : static_cast(m_tabs.size()) - 1; + } + m_hoverIndex = -1; + m_hoverClose = false; + } + + void TabBar::SetActiveIndex(int index) + { + m_activeIndex = index; + } + + float TabBar::DesiredHeight() const + { + return (kTabHeight + kTopMargin) * m_scale; + } + + void TabBar::Layout(const gd::RectF& area) + { + m_area = area; + if (m_fonts == nullptr || m_fonts->ui == nullptr) + { + return; + } + + const float tabHeight = kTabHeight * m_scale; + const float top = area.Y + kTopMargin * m_scale; + const float closeSize = kCloseSize * m_scale; + const float maxWidth = kMaxTabWidth * m_scale; + const float minWidth = 70.0f * m_scale; + + float cursorX = area.X + kLeftPadding * m_scale; + + for (TabItem& tab : m_tabs) + { + const float textWidth = UiMeasureTextWidth(m_fonts->ui, tab.name); + float width = kTabPadding * 2.0f * m_scale + textWidth + kCloseGap * m_scale + closeSize; + width = std::max(minWidth, std::min(width, maxWidth)); + + tab.rect = gd::RectF(cursorX, top, width, tabHeight); + tab.closeRect = gd::RectF(cursorX + width - kTabPadding * m_scale - closeSize, + top + (tabHeight - closeSize) * 0.5f, closeSize, closeSize); + + cursorX += width + kAddGap * m_scale; + } + + const float addSize = kAddSize * m_scale; + m_addRect = gd::RectF(cursorX, top + (tabHeight - addSize) * 0.5f, addSize, addSize); + } + + void TabBar::Paint(gd::Graphics& g) + { + if (m_theme == nullptr || m_fonts == nullptr) + { + return; + } + + UiFillRect(g, m_area, m_theme->panelBg); + + const float closeIconSize = 8.0f * m_scale; + + for (std::size_t index = 0; index < m_tabs.size(); ++index) + { + const TabItem& tab = m_tabs[index]; + const bool active = (static_cast(index) == m_activeIndex); + const bool hovered = (static_cast(index) == m_hoverIndex); + + std::uint32_t background = m_theme->panelBg; + if (active) + { + background = m_theme->cardBg; + } + else if (hovered) + { + background = m_theme->hover; + } + UiFillRoundRect(g, tab.rect, kCorner * m_scale, background); + + if (!active) + { + UiDrawRoundRect(g, tab.rect, kCorner * m_scale, m_theme->border, 1.0f); + } + else + { + // 激活页使用顶部强调条指示。 + UiFillRoundRect(g, gd::RectF(tab.rect.X + kCorner * m_scale, tab.rect.Y, + tab.rect.Width - kCorner * 2.0f * m_scale, 2.5f * m_scale), + 1.0f * m_scale, m_theme->accent); + } + + const float textLeft = tab.rect.X + kTabPadding * m_scale; + const float textWidth = tab.closeRect.X - kCloseGap * m_scale - textLeft; + const std::wstring label = UiEllipsize(g, tab.name, m_fonts->ui, std::max(8.0f, textWidth)); + UiText(g, label, active ? m_fonts->uiBold : m_fonts->ui, + gd::RectF(textLeft, tab.rect.Y, std::max(8.0f, textWidth), tab.rect.Height), + active ? m_theme->textStrong : m_theme->text, TextAlign::LEFT); + + // 关闭按钮:悬停时显示底色,图标始终可见。 + const bool closeHot = hovered && m_hoverClose; + if (closeHot) + { + UiFillRoundRect(g, tab.closeRect, 3.0f * m_scale, m_theme->pressed); + } + const float cx = tab.closeRect.X + tab.closeRect.Width * 0.5f; + const float cy = tab.closeRect.Y + tab.closeRect.Height * 0.5f; + const std::uint32_t iconColor = + closeHot ? m_theme->danger : (active ? m_theme->text : m_theme->textMuted); + UiDrawLine(g, cx - closeIconSize * 0.5f, cy - closeIconSize * 0.5f, cx + closeIconSize * 0.5f, + cy + closeIconSize * 0.5f, iconColor, 1.4f * m_scale); + UiDrawLine(g, cx + closeIconSize * 0.5f, cy - closeIconSize * 0.5f, cx - closeIconSize * 0.5f, + cy + closeIconSize * 0.5f, iconColor, 1.4f * m_scale); + } + + // 新建按钮。 + const std::uint32_t addBg = m_hoverAdd ? m_theme->hover : m_theme->panelBg; + UiFillRoundRect(g, m_addRect, kCorner * m_scale, addBg); + UiDrawRoundRect(g, m_addRect, kCorner * m_scale, m_theme->border, 1.0f); + + const float ax = m_addRect.X + m_addRect.Width * 0.5f; + const float ay = m_addRect.Y + m_addRect.Height * 0.5f; + const float armLength = 4.5f * m_scale; + const std::uint32_t addColor = m_hoverAdd ? m_theme->accent : m_theme->text; + UiDrawLine(g, ax - armLength, ay, ax + armLength, ay, addColor, 1.6f * m_scale); + UiDrawLine(g, ax, ay - armLength, ax, ay + armLength, addColor, 1.6f * m_scale); + + // 底部分隔线。 + UiDrawLine(g, m_area.X, m_area.Y + m_area.Height - 1.0f, m_area.X + m_area.Width, + m_area.Y + m_area.Height - 1.0f, m_theme->border, 1.0f); + } + + bool TabBar::HitTest(int x, int y) const + { + return UiPointInRect(m_area, static_cast(x), static_cast(y)); + } + + int TabBar::TabAtPoint(int x, int y) const + { + const float fx = static_cast(x); + const float fy = static_cast(y); + for (std::size_t index = 0; index < m_tabs.size(); ++index) + { + if (UiPointInRect(m_tabs[index].rect, fx, fy)) + { + return static_cast(index); + } + } + return -1; + } + + bool TabBar::OnMouseMove(int x, int y) + { + const int index = TabAtPoint(x, y); + const bool overClose = + (index >= 0 && UiPointInRect(m_tabs[static_cast(index)].closeRect, + static_cast(x), static_cast(y))); + const bool overAdd = UiPointInRect(m_addRect, static_cast(x), static_cast(y)); + + if (index == m_hoverIndex && overClose == m_hoverClose && overAdd == m_hoverAdd) + { + return false; + } + m_hoverIndex = index; + m_hoverClose = overClose; + m_hoverAdd = overAdd; + return true; + } + + bool TabBar::OnLButtonDown(int x, int y) + { + m_pressedIndex = -1; + m_pressedClose = false; + m_pressedAdd = false; + + if (UiPointInRect(m_addRect, static_cast(x), static_cast(y))) + { + m_pressedAdd = true; + return true; + } + + const int index = TabAtPoint(x, y); + if (index < 0) + { + return false; + } + + m_pressedIndex = index; + m_pressedClose = UiPointInRect(m_tabs[static_cast(index)].closeRect, + static_cast(x), static_cast(y)); + return true; + } + + bool TabBar::OnLButtonUp(int x, int y, TabBarResult& result) + { + result = TabBarResult(); + + if (m_pressedAdd) + { + m_pressedAdd = false; + if (UiPointInRect(m_addRect, static_cast(x), static_cast(y))) + { + result.type = TabBarResult::Type::ADD; + } + return true; + } + + if (m_pressedIndex < 0) + { + return false; + } + + const int index = m_pressedIndex; + const bool wasClose = m_pressedClose; + m_pressedIndex = -1; + m_pressedClose = false; + + if (TabAtPoint(x, y) != index) + { + return true; + } + + if (wasClose) + { + result.type = TabBarResult::Type::CLOSE; + result.index = index; + } + else + { + result.type = TabBarResult::Type::SELECT; + result.index = index; + } + return true; + } + + bool TabBar::OnRButtonDown(int x, int y, int screenX, int screenY, TabBarResult& result) + { + result = TabBarResult(); + + const int index = TabAtPoint(x, y); + if (index < 0) + { + return false; + } + + result.type = TabBarResult::Type::CONTEXT; + result.index = index; + result.screenX = screenX; + result.screenY = screenY; + return true; + } + + bool TabBar::OnMouseLeave() + { + if (m_hoverIndex < 0 && !m_hoverClose && !m_hoverAdd) + { + return false; + } + m_hoverIndex = -1; + m_hoverClose = false; + m_hoverAdd = false; + return true; + } + +} // namespace jdi diff --git a/Components/Toolbar/Inc/Toolbar.hpp b/Components/Toolbar/Inc/Toolbar.hpp index b1aab4c..33646b3 100644 --- a/Components/Toolbar/Inc/Toolbar.hpp +++ b/Components/Toolbar/Inc/Toolbar.hpp @@ -21,13 +21,13 @@ namespace jdi { NONE = 0, /**< 未触发任何动作。 */ OPEN_FILE, /**< 打开 CSV 文件。 */ - FIT_ALL, /**< 时间与幅值同时自适应。 */ - FIT_TIME, /**< 仅时间轴自适应。 */ + FIT_ALL, /**< 视图自适应到可见信号范围。 */ ZOOM_IN, /**< 放大时间轴。 */ ZOOM_OUT, /**< 缩小时间轴。 */ MERGE_LANES, /**< 合并为单一网格。 */ - SPLIT_LANES, /**< 按信号拆分为多网格。 */ - RESET_VIEW /**< 恢复默认视图范围。 */ + SPLIT_LANES, /**< 按信号拆分为多网格。 */ + SELECT_ALL, /**< 勾选当前标签页全部信号。 */ + CLEAR_ALL /**< 取消当前标签页全部信号勾选。 */ }; /** diff --git a/Components/Toolbar/Src/Toolbar.cpp b/Components/Toolbar/Src/Toolbar.cpp index 0916ae9..8a82d41 100644 --- a/Components/Toolbar/Src/Toolbar.cpp +++ b/Components/Toolbar/Src/Toolbar.cpp @@ -71,18 +71,20 @@ namespace jdi const Spec specs[] = { {ToolbarAction::OPEN_FILE, L"打开 CSV", true, false}, - {ToolbarAction::FIT_ALL, L"适应窗口", false, true}, - {ToolbarAction::FIT_TIME, L"适应时间", false, true}, + {ToolbarAction::FIT_ALL, L"适应视图", false, true}, {ToolbarAction::ZOOM_IN, L"放大", false, true}, {ToolbarAction::ZOOM_OUT, L"缩小", false, true}, {ToolbarAction::MERGE_LANES, L"合并网格", false, true}, {ToolbarAction::SPLIT_LANES, L"每信号一格", false, true}, + {ToolbarAction::SELECT_ALL, L"全选信号", false, true}, + {ToolbarAction::CLEAR_ALL, L"清空勾选", false, true}, }; - // 按钮宽度先按固定预算计算,避免依赖绘图上下文。 + // 按钮宽度按实际文本像素宽 + 固定内边距计算,避免中文标签被裁切。 float cursorX = area.X + kSidePadding * m_scale; const float top = area.Y + kTopPadding * m_scale; const float height = kButtonHeight * m_scale; + const float horizontalPadding = kButtonPadding * 2.0f * m_scale; for (const Spec& spec : specs) { @@ -91,8 +93,7 @@ namespace jdi button.label = spec.label; button.primary = spec.primary; button.needsDocument = spec.needsDocument; - button.width = (kButtonPadding * 2.0f + - static_cast(button.label.size()) * 13.0f) * m_scale; + button.width = horizontalPadding + UiMeasureTextWidth(m_fonts->ui, button.label); button.rect = gd::RectF(cursorX, top, button.width, height); cursorX += button.width + kButtonGap * m_scale; diff --git a/Makefile b/Makefile index 38ceddc..35f47b5 100644 --- a/Makefile +++ b/Makefile @@ -79,8 +79,10 @@ UNICODE \ # C++ 头文件目录 CXX_INCLUDES_PATHS = \ Utilities/Inc \ +Resources/Inc \ Components/Main/Inc \ Components/Application/Inc \ +Components/TabBar/Inc \ Components/Toolbar/Inc \ Components/SignalPanel/Inc \ Components/PlotView/Inc \ @@ -90,6 +92,7 @@ CXX_SOURCES_PATHS = \ Utilities/Src \ Components/Main/Src \ Components/Application/Src \ +Components/TabBar/Src \ Components/Toolbar/Src \ Components/SignalPanel/Src \ Components/PlotView/Src \ @@ -99,9 +102,13 @@ CXX_EXTERA_SOURCES = \ # Windows 资源文件脚本头文件路径 WIN_RESOURCE_INCLUDES_PATHS = \ - +Resources \ +Resources/Inc \ +# Windows 资源文件脚本输入代码页(对应 INPUT_CHAR_ENCODING = UTF-8) +WIN_RESOURCE_CODEPAGE = 65001 # Windows 资源文件脚本列表 WIN_RESOURCE_SCRIPTS = \ +Resources/JustDataInspector.rc \ ################################################################################## @@ -216,7 +223,7 @@ $(BUILD_DIR)/$(SUB_DIR_RELEASE)/%.o: %.cpp Makefile | $(BUILD_DIR)/$(SUB_DIR_REL # Release 资源脚本文件编译 $(BUILD_DIR)/$(SUB_DIR_RELEASE)/%.o: %.rc Makefile | $(BUILD_DIR)/$(SUB_DIR_RELEASE) @echo ====== [Release] Windows Resource Script File "$<" Compiling... ====== - $(WIN_RES_COMPLIER) $(foreach path,$(WIN_RESOURCE_INCLUDES_PATHS),$(addprefix -I,$(path))) $< -o $@ + $(WIN_RES_COMPLIER) -c $(WIN_RESOURCE_CODEPAGE) $(foreach path,$(WIN_RESOURCE_INCLUDES_PATHS),$(addprefix -I,$(path))) $< -o $@ # C Debug 目标文件编译 $(BUILD_DIR)/$(SUB_DIR_DEBUG)/%.o: %.c Makefile | $(BUILD_DIR)/$(SUB_DIR_DEBUG) @@ -231,7 +238,7 @@ $(BUILD_DIR)/$(SUB_DIR_DEBUG)/%.o: %.cpp Makefile | $(BUILD_DIR)/$(SUB_DIR_DEBUG # Debug 资源脚本文件编译 $(BUILD_DIR)/$(SUB_DIR_DEBUG)/%.o: %.rc Makefile | $(BUILD_DIR)/$(SUB_DIR_DEBUG) @echo ====== [Debug] Windows Resource Script File "$<" Compiling... ====== - $(WIN_RES_COMPLIER) $(foreach path,$(WIN_RESOURCE_INCLUDES_PATHS),$(addprefix -I,$(path))) $< -o $@ + $(WIN_RES_COMPLIER) -c $(WIN_RESOURCE_CODEPAGE) $(foreach path,$(WIN_RESOURCE_INCLUDES_PATHS),$(addprefix -I,$(path))) $< -o $@ # 依赖关系 diff --git a/Resources/Inc/ResourceIds.h b/Resources/Inc/ResourceIds.h new file mode 100644 index 0000000..f1cd195 --- /dev/null +++ b/Resources/Inc/ResourceIds.h @@ -0,0 +1,15 @@ +#ifndef __RESOURCE_IDS_H__ +#define __RESOURCE_IDS_H__ + +/** + * @file ResourceIds.h + * @brief Windows 资源脚本使用的资源标识符定义。 + */ + +/** @brief 应用程序主图标资源标识符。 */ +#define IDI_APP_ICON 1 + +/** @brief 应用程序清单资源标识符(等价于 CREATEPROCESS_MANIFEST_RESOURCE_ID)。 */ +#define IDR_APP_MANIFEST 1 + +#endif // __RESOURCE_IDS_H__ diff --git a/Resources/JustDataInspector.ico b/Resources/JustDataInspector.ico new file mode 100644 index 0000000..6571331 Binary files /dev/null and b/Resources/JustDataInspector.ico differ diff --git a/Resources/JustDataInspector.manifest b/Resources/JustDataInspector.manifest new file mode 100644 index 0000000..db45f2d --- /dev/null +++ b/Resources/JustDataInspector.manifest @@ -0,0 +1,45 @@ + + + + Just DataInspector - CSV 仿真数据波形查看器 + + + + + + + + + + + + true/pm + permonitorv2,permonitor,system + true + + + + + + + + + + + + + + + + + diff --git a/Resources/JustDataInspector.rc b/Resources/JustDataInspector.rc new file mode 100644 index 0000000..ced8be7 --- /dev/null +++ b/Resources/JustDataInspector.rc @@ -0,0 +1,48 @@ +#include + +#include "ResourceIds.h" + +//////////////////////////////////////////////////////////////////////////////// +// 图标 +//////////////////////////////////////////////////////////////////////////////// + +IDI_APP_ICON ICON "JustDataInspector.ico" + +//////////////////////////////////////////////////////////////////////////////// +// 应用程序清单 +//////////////////////////////////////////////////////////////////////////////// + +IDR_APP_MANIFEST 24 "JustDataInspector.manifest" + +//////////////////////////////////////////////////////////////////////////////// +// 版本信息 +//////////////////////////////////////////////////////////////////////////////// + +VS_VERSION_INFO VERSIONINFO +FILEVERSION 1,0,0,0 +PRODUCTVERSION 1,0,0,0 +FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +FILEFLAGS 0x0L +FILEOS VOS_NT_WINDOWS32 +FILETYPE VFT_APP +FILESUBTYPE VFT2_UNKNOWN +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "080404B0" + BEGIN + VALUE "CompanyName", "Just DataInspector" + VALUE "FileDescription", "Just DataInspector - CSV 仿真数据波形查看器" + VALUE "FileVersion", "1.0.0.0" + VALUE "InternalName", "JustDataInspector" + VALUE "LegalCopyright", "Copyright (C) 2026" + VALUE "OriginalFilename", "Target.exe" + VALUE "ProductName", "Just DataInspector" + VALUE "ProductVersion", "1.0.0.0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x0804, 1200 + END +END diff --git a/Utilities/Inc/DataModel.hpp b/Utilities/Inc/DataModel.hpp new file mode 100644 index 0000000..c1bddde --- /dev/null +++ b/Utilities/Inc/DataModel.hpp @@ -0,0 +1,494 @@ +#ifndef __DATA_MODEL_HPP__ +#define __DATA_MODEL_HPP__ + +#include +#include +#include +#include + +#include "CsvTable.hpp" + +namespace jdi +{ + + /** + * @brief 波形绘制方式。 + */ + enum class DrawMode : int + { + LINE = 0, /**< 相邻采样点之间以插值直线连接。 */ + SCATTER = 1, /**< 仅绘制采样点散点。 */ + LINE_SCATTER = 2, /**< 同时绘制插值直线与采样点散点。 */ + STEP = 3 /**< 阶梯保持(零阶保持)连线。 */ + }; + + /** + * @brief 将绘制方式转换为中文显示名称。 + * @param [IN] mode 绘制方式。 + * @return 显示名称。 + */ + const wchar_t* DrawModeName(DrawMode mode); + + /** + * @brief 网格划分策略:决定信号可见性与网格数量的联动方式。 + */ + enum class LaneMode : int + { + PER_SIGNAL = 0, /**< 每条可见信号独占一个网格(随可见性自动增删)。 */ + MERGED = 1, /**< 全部可见信号叠加在同一网格。 */ + CUSTOM = 2 /**< 由用户手动指定归属;可见性变化时仅清理空网格。 */ + }; + + /** + * @brief 全局信号引用:文件下标 + 文件内信号下标。 + */ + struct SignalRef + { + int fileIndex = -1; /**< 所属文件下标。 */ + int signalIndex = -1; /**< 文件内信号下标。 */ + + /** + * @brief 判断引用是否指向有效位置。 + * @return 两个下标均非负返回 true。 + */ + bool IsValid() const { return fileIndex >= 0 && signalIndex >= 0; } + + /** + * @brief 相等比较。 + * @param [IN] other 另一引用。 + * @return 指向同一信号返回 true。 + */ + bool operator==(const SignalRef& other) const + { + return fileIndex == other.fileIndex && signalIndex == other.signalIndex; + } + + /** + * @brief 不等比较。 + * @param [IN] other 另一引用。 + * @return 指向不同信号返回 true。 + */ + bool operator!=(const SignalRef& other) const { return !(*this == other); } + }; + + /** + * @brief 单条信号的元数据与采样值(纯数据,不含显示配置)。 + */ + struct SignalData + { + std::wstring name; /**< 显示名称(已剥离 "[n]" 序号前缀)。 */ + std::wstring rawHeader; /**< 原始列名。 */ + std::wstring unit; /**< 从列名括号中提取的单位,可能为空。 */ + std::vector values; /**< 采样值,缺失处为 NaN。 */ + double minValue = 0.0; /**< 有限采样值最小值。 */ + double maxValue = 0.0; /**< 有限采样值最大值。 */ + std::size_t finiteCount = 0; /**< 有限采样点数量。 */ + }; + + /** + * @brief 一个已加载的数据文件及其自有时间轴。 + */ + struct DataFile + { + std::wstring filePath; /**< 文件完整路径。 */ + std::wstring fileName; /**< 文件名称。 */ + std::wstring timeColumnName; /**< 时间列列名。 */ + std::vector time; /**< 时间轴数值。 */ + std::vector signals; /**< 该文件包含的信号。 */ + double minTime = 0.0; /**< 时间轴最小值。 */ + double maxTime = 0.0; /**< 时间轴最大值。 */ + bool timeMonotonic = true; /**< 时间轴是否单调不减。 */ + }; + + /** + * @brief 数据集合:统一管理全部已加载文件,与视图配置无关。 + */ + class DataSet + { + public: + /** + * @brief 清空全部已加载文件。 + */ + void Clear(); + + /** + * @brief 依据 CSV 解析结果追加一个数据文件。 + * @param [IN] table CSV 解析结果。 + * @param [IN] filePath 源文件路径。 + * @param [OUT] error 失败原因描述(成功时清空)。 + * @param [OUT] fileIndex 新文件下标,失败时为 -1。 + * @return 追加成功返回 true。 + */ + bool AddFromTable(const CsvTable& table, const std::wstring& filePath, std::wstring& error, int& fileIndex); + + /** + * @brief 移除指定下标的文件。 + * @param [IN] fileIndex 文件下标。 + * @return 移除成功返回 true。 + */ + bool RemoveFile(int fileIndex); + + /** + * @brief 判断数据集合是否为空。 + * @return 未加载任何文件返回 true。 + */ + bool Empty() const { return m_files.empty(); } + + /** + * @brief 获取文件数量。 + * @return 文件数量。 + */ + std::size_t FileCount() const { return m_files.size(); } + + /** + * @brief 获取信号总数。 + * @return 信号总数。 + */ + std::size_t SignalCount() const; + + /** + * @brief 获取全部文件(只读)。 + * @return 文件集合。 + */ + const std::vector& Files() const { return m_files; } + + /** + * @brief 按下标获取文件。 + * @param [IN] fileIndex 文件下标。 + * @return 文件指针,下标非法时返回 nullptr。 + */ + const DataFile* FileAt(int fileIndex) const; + + /** + * @brief 解析信号引用。 + * @param [IN] ref 信号引用。 + * @return 信号数据指针,引用无效时返回 nullptr。 + */ + const SignalData* Resolve(const SignalRef& ref) const; + + /** + * @brief 按路径查找已加载文件下标。 + * @param [IN] filePath 文件完整路径。 + * @return 文件下标,未找到返回 -1。 + */ + int IndexOfFile(const std::wstring& filePath) const; + + /** + * @brief 计算全部文件时间范围的并集下限。 + * @return 时间下限。 + */ + double MinTime() const; + + /** + * @brief 计算全部文件时间范围的并集上限。 + * @return 时间上限。 + */ + double MaxTime() const; + + private: + std::vector m_files; /**< 已加载文件集合。 */ + }; + + /** + * @brief 单个信号的显示配置(属于某个标签页)。 + */ + struct SignalStyle + { + bool visible = false; /**< 是否参与绘制。默认关闭,由用户自行勾选。 */ + std::uint32_t color = 0xFF268BD2; /**< 波形颜色,格式 0xAARRGGBB。 */ + float lineWidth = 1.6f; /**< 线宽(逻辑像素,未乘 DPI 缩放)。 */ + DrawMode mode = DrawMode::LINE; /**< 绘制方式。 */ + }; + + /** + * @brief 一个波形网格(纵向泳道),可容纳多条信号叠加显示。 + */ + struct Lane + { + std::wstring name; /**< 网格名称。 */ + std::vector signals; /**< 该网格包含的信号(按加入顺序)。 */ + bool autoRange = true; /**< 是否依据信号取值范围自动计算 Y 轴。 */ + double yMin = -1.0; /**< 手动 Y 轴下限。 */ + double yMax = 1.0; /**< 手动 Y 轴上限。 */ + }; + + /** + * @brief 计算某条信号在给定时刻的取值。 + * @param [IN] file 信号所属文件。 + * @param [IN] signal 目标信号。 + * @param [IN] time 采样时刻。 + * @param [IN] interpolate 是否在相邻采样点之间做线性插值。 + * @param [OUT] value 输出采样值,缺失时为 NaN。 + * @return 时间点落在数据范围内且取到有效值返回 true。 + */ + bool SampleFileAt(const DataFile& file, const SignalData& signal, double time, bool interpolate, double& value); + + /** + * @brief 在指定文件的时间轴上查找与给定时刻最接近的采样点。 + * @param [IN] file 目标文件。 + * @param [IN] time 采样时刻。 + * @return 采样点下标,文件为空时返回 -1。 + */ + int FindNearestSample(const DataFile& file, double time); + + /** + * @brief 单个标签页的独立视图配置(信号可见性、样式、网格划分与视图范围)。 + */ + class TabConfig + { + public: + TabConfig(); + + /** + * @brief 获取标签页名称。 + * @return 名称。 + */ + const std::wstring& Name() const { return m_name; } + + /** + * @brief 设置标签页名称。 + * @param [IN] name 新名称。 + */ + void SetName(const std::wstring& name) { m_name = name; } + + /** + * @brief 依据数据集合调整配置容量(新增文件补默认配置,移除文件清理配置)。 + * @param [IN] dataSet 数据集合。 + */ + void SyncToDataSet(const DataSet& dataSet); + + /** + * @brief 在数据层移除某文件之前,按旧下标修正本配置(不重建网格)。 + * @param [IN] fileIndex 即将被移除的文件下标。 + * @note 调用顺序必须为:各标签页 RemapAfterFileRemoval → 数据层 RemoveFile → 各标签页 SyncToDataSet。 + */ + void RemapAfterFileRemoval(int fileIndex); + + /** + * @brief 获取指定信号的显示配置(只读)。 + * @param [IN] ref 信号引用。 + * @return 配置指针,引用无效时返回 nullptr。 + */ + const SignalStyle* Style(const SignalRef& ref) const; + + /** + * @brief 获取指定信号的显示配置(可写)。 + * @param [IN] ref 信号引用。 + * @return 配置指针,引用无效时返回 nullptr。 + */ + SignalStyle* Style(const SignalRef& ref); + + /** + * @brief 判断指定信号是否可见。 + * @param [IN] ref 信号引用。 + * @return 可见返回 true。 + */ + bool IsVisible(const SignalRef& ref) const; + + /** + * @brief 设置指定信号的可见性,并同步网格划分。 + * @param [IN] dataSet 数据集合。 + * @param [IN] ref 信号引用。 + * @param [IN] visible 目标可见状态。 + */ + void SetVisible(const DataSet& dataSet, const SignalRef& ref, bool visible); + + /** + * @brief 切换指定信号的可见性,并同步网格划分。 + * @param [IN] dataSet 数据集合。 + * @param [IN] ref 信号引用。 + */ + void ToggleVisible(const DataSet& dataSet, const SignalRef& ref); + + /** + * @brief 设置某个文件全部信号的可见性,并同步网格划分。 + * @param [IN] dataSet 数据集合。 + * @param [IN] fileIndex 文件下标。 + * @param [IN] visible 目标可见状态。 + */ + void SetFileVisible(const DataSet& dataSet, int fileIndex, bool visible); + + /** + * @brief 设置全部信号可见性,并同步网格划分。 + * @param [IN] dataSet 数据集合。 + * @param [IN] visible 目标可见状态。 + */ + void SetAllVisible(const DataSet& dataSet, bool visible); + + /** + * @brief 仅显示指定信号,其余全部隐藏。 + * @param [IN] dataSet 数据集合。 + * @param [IN] ref 需要保持可见的信号。 + */ + void SoloSignal(const DataSet& dataSet, const SignalRef& ref); + + /** + * @brief 判断某文件是否存在可见信号。 + * @param [IN] dataSet 数据集合。 + * @param [IN] fileIndex 文件下标。 + * @return 存在可见信号返回 true。 + */ + bool FileHasVisible(const DataSet& dataSet, int fileIndex) const; + + /** + * @brief 判断某文件是否全部信号可见。 + * @param [IN] dataSet 数据集合。 + * @param [IN] fileIndex 文件下标。 + * @return 全部可见且至少有一条信号返回 true。 + */ + bool FileAllVisible(const DataSet& dataSet, int fileIndex) const; + + /** + * @brief 判断是否存在任何可见信号。 + * @return 存在返回 true。 + */ + bool AnyVisible() const; + + /** + * @brief 统计可见信号数量。 + * @return 可见信号数量。 + */ + int VisibleCount() const; + + /** + * @brief 获取网格划分策略。 + * @return 策略枚举。 + */ + LaneMode Mode() const { return m_laneMode; } + + /** + * @brief 设置网格划分策略并同步网格。 + * @param [IN] dataSet 数据集合。 + * @param [IN] mode 目标策略。 + */ + void SetMode(const DataSet& dataSet, LaneMode mode); + + /** + * @brief 依据当前可见性与策略重新生成网格划分。 + * @param [IN] dataSet 数据集合。 + */ + void SyncLanes(const DataSet& dataSet); + + /** + * @brief 获取网格集合(只读)。 + * @return 网格集合。 + */ + const std::vector& Lanes() const { return m_lanes; } + + /** + * @brief 获取网格集合(可写)。 + * @return 网格集合。 + */ + std::vector& Lanes() { return m_lanes; } + + /** + * @brief 查询某信号所属网格下标。 + * @param [IN] ref 信号引用。 + * @return 网格下标,未找到返回 -1。 + */ + int LaneOfSignal(const SignalRef& ref) const; + + /** + * @brief 将信号移动到指定网格,并切换为自定义划分策略。 + * @param [IN] dataSet 数据集合。 + * @param [IN] ref 信号引用。 + * @param [IN] laneIndex 目标网格下标,传入负值表示新建网格。 + * @return 移动后该信号所在的网格下标。 + */ + int MoveSignalToLane(const DataSet& dataSet, const SignalRef& ref, int laneIndex); + + /** + * @brief 查询某文件的分组是否处于折叠状态。 + * @param [IN] fileIndex 文件下标。 + * @return 折叠状态。 + */ + bool IsCollapsed(int fileIndex) const; + + /** + * @brief 切换某文件分组的折叠状态。 + * @param [IN] fileIndex 文件下标。 + */ + void ToggleCollapsed(int fileIndex); + + /** + * @brief 获取当前选中的信号引用。 + * @return 信号引用。 + */ + SignalRef Selected() const { return m_selected; } + + /** + * @brief 设置当前选中的信号。 + * @param [IN] ref 信号引用,无效值表示取消选择。 + */ + void SetSelected(const SignalRef& ref) { m_selected = ref; } + + /** + * @brief 计算某网格的 Y 轴显示范围。 + * @param [IN] dataSet 数据集合。 + * @param [IN] laneIndex 网格下标。 + * @param [OUT] yMin 输出下限。 + * @param [OUT] yMax 输出上限。 + */ + void ComputeLaneRange(const DataSet& dataSet, int laneIndex, double& yMin, double& yMax) const; + + /** + * @brief 计算全部可见信号覆盖的时间范围。 + * @param [IN] dataSet 数据集合。 + * @param [OUT] minTime 输出下限。 + * @param [OUT] maxTime 输出上限。 + * @return 存在可见信号返回 true。 + */ + bool ComputeVisibleTimeBounds(const DataSet& dataSet, double& minTime, double& maxTime) const; + + /// @brief 视图左边界时间(可读写)。 + double& ViewMin() { return m_viewMin; } + /// @brief 视图左边界时间(只读)。 + double ViewMin() const { return m_viewMin; } + /// @brief 视图右边界时间(可读写)。 + double& ViewMax() { return m_viewMax; } + /// @brief 视图右边界时间(只读)。 + double ViewMax() const { return m_viewMax; } + /// @brief 视图范围是否已初始化(可读写)。 + bool& ViewInitialized() { return m_viewInitialized; } + /// @brief 网格纵向滚动偏移量(可读写)。 + float& LaneScroll() { return m_laneScroll; } + /// @brief 时间光标是否可见(可读写)。 + bool& CursorVisible() { return m_cursorVisible; } + /// @brief 时间光标是否可见(只读)。 + bool CursorVisible() const { return m_cursorVisible; } + /// @brief 时间光标是否已固定(可读写)。 + bool& CursorPinned() { return m_cursorPinned; } + /// @brief 时间光标是否已固定(只读)。 + bool CursorPinned() const { return m_cursorPinned; } + /// @brief 时间光标位置(可读写)。 + double& CursorTime() { return m_cursorTime; } + /// @brief 时间光标位置(只读)。 + double CursorTime() const { return m_cursorTime; } + + private: + /** + * @brief 为新增信号补默认样式(含调色板取色)。 + * @param [IN] dataSet 数据集合。 + * @param [IN] fileIndex 文件下标。 + * @param [IN] signalIndex 信号下标。 + */ + void EnsureStyle(const DataSet& dataSet, int fileIndex, int signalIndex); + + std::wstring m_name; /**< 标签页名称。 */ + std::vector> m_styles; /**< 显示配置,按 [文件][信号] 索引。 */ + std::vector m_collapsed; /**< 文件分组折叠状态,按文件索引。 */ + std::vector m_lanes; /**< 网格划分。 */ + LaneMode m_laneMode = LaneMode::PER_SIGNAL; /**< 网格划分策略。 */ + SignalRef m_selected; /**< 当前选中信号。 */ + double m_viewMin = 0.0; /**< 视图左边界时间。 */ + double m_viewMax = 1.0; /**< 视图右边界时间。 */ + bool m_viewInitialized = false; /**< 视图范围是否已初始化。 */ + float m_laneScroll = 0.0f; /**< 网格纵向滚动偏移量。 */ + bool m_cursorVisible = false; /**< 时间光标是否可见。 */ + bool m_cursorPinned = false; /**< 时间光标是否已固定。 */ + double m_cursorTime = 0.0; /**< 时间光标位置。 */ + }; + +} // namespace jdi + +#endif // __DATA_MODEL_HPP__ diff --git a/Utilities/Inc/PaintUtil.hpp b/Utilities/Inc/PaintUtil.hpp index 54cc95c..903f47e 100644 --- a/Utilities/Inc/PaintUtil.hpp +++ b/Utilities/Inc/PaintUtil.hpp @@ -148,6 +148,14 @@ namespace jdi */ float UiTextWidth(gd::Graphics& g, const std::wstring& text, HFONT font); + /** + * @brief 测量单行文本的像素宽度(无需 GDI+ 上下文,可在布局阶段调用)。 + * @param [IN] font 字体句柄。 + * @param [IN] text 文本内容。 + * @return 文本像素宽度。 + */ + float UiMeasureTextWidth(HFONT font, const std::wstring& text); + /** * @brief 在超出可用宽度时对文本做尾部省略截断。 * @param [IN,OUT] g 绘图上下文。 diff --git a/Utilities/Inc/SignalSet.hpp b/Utilities/Inc/SignalSet.hpp deleted file mode 100644 index 286d18b..0000000 --- a/Utilities/Inc/SignalSet.hpp +++ /dev/null @@ -1,212 +0,0 @@ -#ifndef __SIGNAL_SET_HPP__ -#define __SIGNAL_SET_HPP__ - -#include -#include -#include -#include - -#include "CsvTable.hpp" - -namespace jdi -{ - - /** - * @brief 波形绘制方式。 - */ - enum class DrawMode : int - { - LINE = 0, /**< 相邻采样点之间以插值直线连接。 */ - SCATTER = 1, /**< 仅绘制采样点散点。 */ - LINE_SCATTER = 2, /**< 同时绘制插值直线与采样点散点。 */ - STEP = 3 /**< 阶梯保持(零阶保持)连线。 */ - }; - - /** - * @brief 将绘制方式转换为中文显示名称。 - * @param [IN] mode 绘制方式。 - * @return 显示名称。 - */ - const wchar_t* DrawModeName(DrawMode mode); - - /** - * @brief 单条信号(一列数据及其显示属性)。 - */ - struct Signal - { - std::wstring name; /**< 显示名称(已剥离 "[n]" 序号前缀)。 */ - std::wstring rawHeader; /**< CSV 原始列名。 */ - std::wstring unit; /**< 从列名括号中提取的单位,可能为空。 */ - std::vector values; /**< 采样值,缺失处为 NaN。 */ - bool visible = true; /**< 是否参与绘制。 */ - std::uint32_t color = 0xFF268BD2; /**< 波形颜色,格式 0xAARRGGBB。 */ - float lineWidth = 1.6f; /**< 线宽(逻辑像素,未乘 DPI 缩放)。 */ - DrawMode mode = DrawMode::LINE; /**< 绘制方式。 */ - double minValue = 0.0; /**< 有限采样值最小值。 */ - double maxValue = 0.0; /**< 有限采样值最大值。 */ - std::size_t finiteCount = 0; /**< 有限采样点数量。 */ - }; - - /** - * @brief 一个波形网格(纵向泳道),可容纳多条信号叠加显示。 - */ - struct Lane - { - std::wstring name; /**< 网格名称。 */ - std::vector signalIndices; /**< 该网格包含的信号下标(按加入顺序)。 */ - bool autoRange = true; /**< 是否依据信号取值范围自动计算 Y 轴。 */ - double yMin = -1.0; /**< 手动 Y 轴下限。 */ - double yMax = 1.0; /**< 手动 Y 轴上限。 */ - }; - - /** - * @brief 仿真数据集合:时间轴 + 多条信号 + 网格划分。 - */ - class SignalSet - { - public: - SignalSet(); - - /** - * @brief 清空全部数据与显示状态。 - */ - void Clear(); - - /** - * @brief 依据 CSV 解析结果构建数据集(自动识别时间列与信号列)。 - * @param [IN] table CSV 解析结果。 - * @param [IN] filePath 源文件路径,用于界面展示。 - * @param [OUT] error 失败原因描述。 - * @return 构建成功返回 true。 - */ - bool BuildFromTable(const CsvTable& table, const std::wstring& filePath, std::wstring& error); - - /** - * @brief 判断数据集是否已加载数据。 - * @return 已加载返回 true。 - */ - bool IsLoaded() const { return !m_signals.empty(); } - - /// @brief 信号集合(可读写)。 - std::vector& Signals() { return m_signals; } - /// @brief 信号集合(只读)。 - const std::vector& Signals() const { return m_signals; } - /// @brief 网格集合(可读写)。 - std::vector& Lanes() { return m_lanes; } - /// @brief 网格集合(只读)。 - const std::vector& Lanes() const { return m_lanes; } - /// @brief 时间轴数值(只读)。 - const std::vector& Time() const { return m_time; } - /// @brief 时间轴列名(只读)。 - const std::wstring& TimeColumnName() const { return m_timeColumnName; } - /// @brief 源文件路径(只读)。 - const std::wstring& FilePath() const { return m_filePath; } - /// @brief 源文件名称(只读)。 - const std::wstring& FileName() const { return m_fileName; } - /// @brief 采样点数量(只读)。 - std::size_t SampleCount() const { return m_time.size(); } - /// @brief 时间轴最小值(只读)。 - double MinTime() const { return m_minTime; } - /// @brief 时间轴最大值(只读)。 - double MaxTime() const { return m_maxTime; } - /// @brief 时间轴是否单调不减(只读)。 - bool TimeMonotonic() const { return m_timeMonotonic; } - /// @brief 当前选中信号下标,-1 表示未选中(只读)。 - int SelectedSignal() const { return m_selectedSignal; } - - /** - * @brief 设置当前选中信号。 - * @param [IN] index 信号下标,传入越界值等价于取消选择。 - */ - void SetSelectedSignal(int index); - - /** - * @brief 查询某信号所属网格下标。 - * @param [IN] signalIndex 信号下标。 - * @return 网格下标,未找到返回 -1。 - */ - int LaneOfSignal(int signalIndex) const; - - /** - * @brief 将信号移动到指定网格,必要时创建新网格并回收空网格。 - * @param [IN] signalIndex 信号下标。 - * @param [IN] laneIndex 目标网格下标,传入负值表示新建网格。 - * @return 移动后该信号所在的网格下标。 - */ - int MoveSignalToLane(int signalIndex, int laneIndex); - - /** - * @brief 将所有可见信号合并到同一个网格中。 - */ - void MergeIntoSingleLane(); - - /** - * @brief 为每条信号创建独立网格。 - */ - void SplitIntoLanes(); - - /** - * @brief 设置全部信号的可见状态。 - * @param [IN] visible 目标可见状态。 - */ - void SetAllVisible(bool visible); - - /** - * @brief 仅显示指定信号,其余全部隐藏。 - * @param [IN] signalIndex 需要保持可见的信号下标。 - */ - void SoloSignal(int signalIndex); - - /** - * @brief 计算某网格的 Y 轴显示范围。 - * @param [IN] laneIndex 网格下标。 - * @param [OUT] yMin 输出下限。 - * @param [OUT] yMax 输出上限。 - */ - void ComputeLaneRange(int laneIndex, double& yMin, double& yMax) const; - - /** - * @brief 在当前时间轴上采样指定信号的值。 - * @param [IN] signalIndex 信号下标。 - * @param [IN] time 采样时刻。 - * @param [IN] interpolate 是否在相邻采样点之间做线性插值。 - * @param [OUT] value 输出采样值,缺失时为 NaN。 - * @return 时间点落在数据范围内且取到有效值返回 true。 - */ - bool SampleAt(int signalIndex, double time, bool interpolate, double& value) const; - - /** - * @brief 在当前数据集中查找时间最为接近的采样点下标。 - * @param [IN] time 采样时刻。 - * @return 采样点下标,数据集为空时返回 -1。 - */ - int FindNearestSample(double time) const; - - private: - /** - * @brief 移除没有包含任何信号的网格(至少保留一个)。 - */ - void PruneEmptyLanes(); - - /** - * @brief 为新增信号分配默认颜色。 - * @param [IN] index 信号序号。 - * @return 0xAARRGGBB 颜色值。 - */ - std::uint32_t PickDefaultColor(std::size_t index) const; - - std::vector m_time; /**< 时间轴数值。 */ - std::vector m_signals; /**< 信号集合。 */ - std::vector m_lanes; /**< 网格集合。 */ - std::wstring m_timeColumnName; /**< 时间列列名。 */ - std::wstring m_filePath; /**< 源文件完整路径。 */ - std::wstring m_fileName; /**< 源文件名称。 */ - double m_minTime = 0.0; /**< 时间轴最小值。 */ - double m_maxTime = 0.0; /**< 时间轴最大值。 */ - bool m_timeMonotonic = true; /**< 时间轴是否单调不减。 */ - int m_selectedSignal = -1; /**< 当前选中信号下标。 */ - }; - -} // namespace jdi - -#endif // __SIGNAL_SET_HPP__ diff --git a/Utilities/Src/DataModel.cpp b/Utilities/Src/DataModel.cpp new file mode 100644 index 0000000..04e135f --- /dev/null +++ b/Utilities/Src/DataModel.cpp @@ -0,0 +1,1111 @@ +#include "DataModel.hpp" + +#include +#include +#include +#include + +#include "Theme.hpp" +#include "Utf8Util.hpp" + +namespace jdi +{ + + namespace + { + const double kMissing = std::numeric_limits::quiet_NaN(); /**< 缺失值占位。 */ + const double kDefaultLineWidth = 1.6; /**< 默认线宽。 */ + + /** + * @brief 判断列名是否为时间轴候选列。 + * @param [IN] header 原始列名。 + * @return 可能为时间轴返回 true。 + */ + bool LooksLikeTimeHeader(const std::wstring& header) + { + const std::wstring text = ToLowerAscii(Trim(header)); + if (text.empty()) + { + return false; + } + + if (text == L"t" || text == L"time" || text == L"timestamp" || text == L"simulation time" || + text == L"时间" || text == L"时间轴") + { + return true; + } + return ContainsInsensitive(text, L"time") || ContainsInsensitive(text, L"时间"); + } + + /** + * @brief 剥离列名中的 "[n]" 序号前缀,得到显示名称。 + * @param [IN] header 原始列名。 + * @return 显示名称。 + */ + std::wstring MakeDisplayName(const std::wstring& header) + { + const std::wstring text = Trim(header); + if (text.size() >= 3 && text[0] == L'[') + { + const std::size_t close = text.find(L']'); + if (close != std::wstring::npos && close > 1 && (close + 1) < text.size()) + { + bool numeric = true; + for (std::size_t i = 1; i < close; ++i) + { + if (!iswdigit(text[i])) + { + numeric = false; + break; + } + } + if (numeric) + { + const std::wstring rest = Trim(text.substr(close + 1)); + if (!rest.empty()) + { + return rest; + } + } + } + } + return text.empty() ? header : text; + } + + /** + * @brief 从列名末尾的括号中提取单位(仅接受形如 mA / V / deg/s 的短标记)。 + * @param [IN] header 原始列名。 + * @return 单位文本,无法识别时返回空字符串。 + */ + std::wstring ExtractUnit(const std::wstring& header) + { + const std::wstring text = Trim(header); + if (text.size() < 3 || text.back() != L')') + { + return std::wstring(); + } + + const std::size_t open = text.rfind(L'('); + if (open == std::wstring::npos || open + 2 >= text.size()) + { + return std::wstring(); + } + + const std::wstring inner = Trim(text.substr(open + 1, text.size() - open - 2)); + if (inner.empty() || inner.size() > 10) + { + return std::wstring(); + } + + for (const wchar_t c : inner) + { + const bool allowed = iswalpha(c) || iswdigit(c) || c == L'%' || c == L'/' || c == L'^' || + c == L'-' || c == L'.' || c == L'\u00B0' || c == L'\u00B5' || + c == L'\u03A9' || c == L'\u00B2' || c == L'\u00B3' || c == L'\u00B7'; + if (!allowed) + { + return std::wstring(); + } + } + return inner; + } + + /** + * @brief 依据全局信号序号从调色板取色,保证跨标签页配色一致。 + * @param [IN] globalIndex 全局信号序号。 + * @return 0xAARRGGBB 颜色值。 + */ + std::uint32_t PaletteColor(std::size_t globalIndex) + { + int paletteCount = 0; + const std::uint32_t* palette = SignalPalette(paletteCount); + if (palette == nullptr || paletteCount <= 0) + { + return 0xFF268BD2; + } + return palette[globalIndex % static_cast(paletteCount)]; + } + + /** + * @brief 为网格范围补充留白,避免波形紧贴边界。 + * @param [IN, OUT] yMin 范围下限。 + * @param [IN, OUT] yMax 范围上限。 + */ + void PadRange(double& yMin, double& yMax) + { + if (!std::isfinite(yMin) || !std::isfinite(yMax)) + { + yMin = 0.0; + yMax = 1.0; + return; + } + + const double span = yMax - yMin; + if (span <= 0.0) + { + const double magnitude = std::max(1.0, std::fabs(yMin) * 0.1); + yMin -= magnitude; + yMax += magnitude; + return; + } + + const double pad = span * 0.06; + yMin -= pad; + yMax += pad; + } + + /** + * @brief 依据全部可见信号重新生成网格划分。 + * @param [IN] styles 显示配置。 + * @param [IN] lanes 网格集合(原地更新)。 + * @param [IN] mode 网格划分策略。 + */ + void RebuildLanes(const std::vector>& styles, std::vector& lanes, + LaneMode mode) + { + // 收集全部可见信号引用。 + std::vector visible; + for (std::size_t fileIndex = 0; fileIndex < styles.size(); ++fileIndex) + { + for (std::size_t signalIndex = 0; signalIndex < styles[fileIndex].size(); ++signalIndex) + { + if (styles[fileIndex][signalIndex].visible) + { + SignalRef ref; + ref.fileIndex = static_cast(fileIndex); + ref.signalIndex = static_cast(signalIndex); + visible.push_back(ref); + } + } + } + + if (mode == LaneMode::PER_SIGNAL) + { + // 每条可见信号独占一个网格:网格数量严格跟随可见信号数量。 + lanes.clear(); + lanes.reserve(visible.size()); + for (const SignalRef& ref : visible) + { + Lane lane; + lane.autoRange = true; + lane.signals.push_back(ref); + lanes.push_back(std::move(lane)); + } + } + else if (mode == LaneMode::MERGED) + { + // 全部可见信号合并到同一网格;无可见信号时不保留空网格。 + bool previousAutoRange = true; + double previousYMin = -1.0; + double previousYMax = 1.0; + if (lanes.size() == 1) + { + previousAutoRange = lanes[0].autoRange; + previousYMin = lanes[0].yMin; + previousYMax = lanes[0].yMax; + } + + lanes.clear(); + if (!visible.empty()) + { + Lane lane; + lane.autoRange = previousAutoRange; + lane.yMin = previousYMin; + lane.yMax = previousYMax; + lane.signals = visible; + lanes.push_back(std::move(lane)); + } + } + else + { + // 自定义划分:仅移除不可见信号与由此产生的空网格,保持用户排布。 + for (Lane& lane : lanes) + { + std::vector& refs = lane.signals; + refs.erase(std::remove_if(refs.begin(), refs.end(), + [&styles](const SignalRef& ref) + { + if (ref.fileIndex < 0 || + static_cast(ref.fileIndex) >= styles.size()) + { + return true; + } + const std::vector& fileStyles = + styles[static_cast(ref.fileIndex)]; + if (ref.signalIndex < 0 || + static_cast(ref.signalIndex) >= fileStyles.size()) + { + return true; + } + return !fileStyles[static_cast(ref.signalIndex)].visible; + }), + refs.end()); + } + + lanes.erase(std::remove_if(lanes.begin(), lanes.end(), + [](const Lane& lane) { return lane.signals.empty(); }), + lanes.end()); + + // 未分配网格的可见信号统一追加到第一个网格。 + for (const SignalRef& ref : visible) + { + bool placed = false; + for (const Lane& lane : lanes) + { + if (std::find(lane.signals.begin(), lane.signals.end(), ref) != lane.signals.end()) + { + placed = true; + break; + } + } + if (placed) + { + continue; + } + + if (lanes.empty()) + { + Lane lane; + lane.autoRange = true; + lanes.push_back(std::move(lane)); + } + lanes[0].signals.push_back(ref); + } + } + + // 统一重命名,保证编号连续。 + for (std::size_t index = 0; index < lanes.size(); ++index) + { + lanes[index].name = L"网格 " + std::to_wstring(index + 1); + } + } + } + + const wchar_t* DrawModeName(DrawMode mode) + { + switch (mode) + { + case DrawMode::LINE: + return L"线段"; + case DrawMode::SCATTER: + return L"散点"; + case DrawMode::LINE_SCATTER: + return L"点线"; + case DrawMode::STEP: + return L"阶梯"; + default: + return L"线段"; + } + } + + // ============================ DataSet ============================ + + void DataSet::Clear() + { + m_files.clear(); + } + + bool DataSet::AddFromTable(const CsvTable& table, const std::wstring& filePath, std::wstring& error, + int& fileIndex) + { + error.clear(); + fileIndex = -1; + + if (!table.HasData()) + { + error = L"数据表为空。"; + return false; + } + + const std::size_t columnCount = table.headers.size(); + const std::size_t rowCount = table.rows.size(); + + DataFile file; + + // 1. 识别时间轴列:优先取列名匹配的列,否则回退到第一列。 + int timeColumn = -1; + for (std::size_t column = 0; column < columnCount; ++column) + { + if (LooksLikeTimeHeader(table.headers[column])) + { + timeColumn = static_cast(column); + break; + } + } + if (timeColumn < 0) + { + timeColumn = 0; + } + + file.time.resize(rowCount); + std::size_t finiteTimeCount = 0; + bool monotonic = true; + double previousTime = 0.0; + bool hasPrevious = false; + + for (std::size_t row = 0; row < rowCount; ++row) + { + const double value = table.rows[row][static_cast(timeColumn)]; + file.time[row] = value; + if (std::isfinite(value)) + { + ++finiteTimeCount; + if (hasPrevious && value < previousTime) + { + monotonic = false; + } + previousTime = value; + hasPrevious = true; + } + } + + if (finiteTimeCount < 2) + { + error = L"时间轴列(" + table.headers[static_cast(timeColumn)] + L")有效数值不足。"; + return false; + } + + file.timeColumnName = table.headers[static_cast(timeColumn)]; + file.timeMonotonic = monotonic; + file.filePath = filePath; + + const std::size_t slashPos = filePath.find_last_of(L"\\/"); + file.fileName = (slashPos == std::wstring::npos) ? filePath : filePath.substr(slashPos + 1); + + // 2. 构建信号列。 + file.signals.reserve(columnCount); + for (std::size_t column = 0; column < columnCount; ++column) + { + if (static_cast(column) == timeColumn) + { + continue; + } + + SignalData signal; + signal.rawHeader = table.headers[column]; + signal.name = MakeDisplayName(signal.rawHeader); + signal.unit = ExtractUnit(signal.rawHeader); + + signal.values.resize(rowCount); + double minValue = std::numeric_limits::infinity(); + double maxValue = -std::numeric_limits::infinity(); + std::size_t finiteCount = 0; + + for (std::size_t row = 0; row < rowCount; ++row) + { + const double value = table.rows[row][column]; + signal.values[row] = value; + if (std::isfinite(value)) + { + ++finiteCount; + minValue = std::min(minValue, value); + maxValue = std::max(maxValue, value); + } + } + + if (finiteCount == 0) + { + signal.minValue = 0.0; + signal.maxValue = 0.0; + } + else + { + signal.minValue = minValue; + signal.maxValue = maxValue; + } + signal.finiteCount = finiteCount; + + file.signals.push_back(std::move(signal)); + } + + if (file.signals.empty()) + { + error = L"除时间轴外未发现任何数据列。"; + return false; + } + + // 3. 统计时间范围。 + double tMin = std::numeric_limits::infinity(); + double tMax = -std::numeric_limits::infinity(); + for (const double value : file.time) + { + if (std::isfinite(value)) + { + tMin = std::min(tMin, value); + tMax = std::max(tMax, value); + } + } + file.minTime = tMin; + file.maxTime = tMax; + + m_files.push_back(std::move(file)); + fileIndex = static_cast(m_files.size()) - 1; + return true; + } + + bool DataSet::RemoveFile(int fileIndex) + { + if (fileIndex < 0 || fileIndex >= static_cast(m_files.size())) + { + return false; + } + m_files.erase(m_files.begin() + fileIndex); + return true; + } + + std::size_t DataSet::SignalCount() const + { + std::size_t count = 0; + for (const DataFile& file : m_files) + { + count += file.signals.size(); + } + return count; + } + + const DataFile* DataSet::FileAt(int fileIndex) const + { + if (fileIndex < 0 || fileIndex >= static_cast(m_files.size())) + { + return nullptr; + } + return &m_files[static_cast(fileIndex)]; + } + + const SignalData* DataSet::Resolve(const SignalRef& ref) const + { + const DataFile* file = FileAt(ref.fileIndex); + if (file == nullptr) + { + return nullptr; + } + if (ref.signalIndex < 0 || ref.signalIndex >= static_cast(file->signals.size())) + { + return nullptr; + } + return &file->signals[static_cast(ref.signalIndex)]; + } + + int DataSet::IndexOfFile(const std::wstring& filePath) const + { + for (std::size_t index = 0; index < m_files.size(); ++index) + { + if (m_files[index].filePath == filePath) + { + return static_cast(index); + } + } + return -1; + } + + double DataSet::MinTime() const + { + double value = std::numeric_limits::infinity(); + for (const DataFile& file : m_files) + { + if (std::isfinite(file.minTime)) + { + value = std::min(value, file.minTime); + } + } + return std::isfinite(value) ? value : 0.0; + } + + double DataSet::MaxTime() const + { + double value = -std::numeric_limits::infinity(); + for (const DataFile& file : m_files) + { + if (std::isfinite(file.maxTime)) + { + value = std::max(value, file.maxTime); + } + } + return std::isfinite(value) ? value : 1.0; + } + + // ============================ 采样辅助 ============================ + + int FindNearestSample(const DataFile& file, double time) + { + const std::size_t count = file.time.size(); + if (count == 0) + { + return -1; + } + + if (file.timeMonotonic) + { + const std::vector::const_iterator lower = + std::lower_bound(file.time.begin(), file.time.end(), time); + if (lower == file.time.begin()) + { + return 0; + } + if (lower == file.time.end()) + { + return static_cast(count - 1); + } + const std::size_t rightIndex = static_cast(lower - file.time.begin()); + const std::size_t leftIndex = rightIndex - 1; + const double leftDistance = std::fabs(time - file.time[leftIndex]); + const double rightDistance = std::fabs(file.time[rightIndex] - time); + return (leftDistance <= rightDistance) ? static_cast(leftIndex) : static_cast(rightIndex); + } + + int best = 0; + double bestDistance = std::numeric_limits::infinity(); + for (std::size_t index = 0; index < count; ++index) + { + if (!std::isfinite(file.time[index])) + { + continue; + } + const double distance = std::fabs(file.time[index] - time); + if (distance < bestDistance) + { + bestDistance = distance; + best = static_cast(index); + } + } + return best; + } + + bool SampleFileAt(const DataFile& file, const SignalData& signal, double time, bool interpolate, double& value) + { + value = kMissing; + + if (file.time.empty() || signal.values.empty()) + { + return false; + } + if (time < file.minTime || time > file.maxTime) + { + return false; + } + + const std::vector& values = signal.values; + + if (!file.timeMonotonic) + { + const int nearest = FindNearestSample(file, time); + if (nearest < 0 || static_cast(nearest) >= values.size()) + { + return false; + } + value = values[static_cast(nearest)]; + return std::isfinite(value); + } + + const std::vector::const_iterator lower = std::lower_bound(file.time.begin(), file.time.end(), time); + const std::size_t rightIndex = static_cast(lower - file.time.begin()); + if (rightIndex == 0) + { + value = values[0]; + return std::isfinite(value); + } + if (rightIndex >= file.time.size()) + { + value = values.back(); + return std::isfinite(value); + } + + const std::size_t leftIndex = rightIndex - 1; + if (rightIndex >= values.size() || leftIndex >= values.size()) + { + return false; + } + + const double t0 = file.time[leftIndex]; + const double t1 = file.time[rightIndex]; + const double v0 = values[leftIndex]; + const double v1 = values[rightIndex]; + + if (!interpolate || !std::isfinite(v0) || !std::isfinite(v1) || !(t1 > t0)) + { + const double leftDistance = std::fabs(time - t0); + const double rightDistance = std::fabs(t1 - time); + value = (leftDistance <= rightDistance) ? v0 : v1; + return std::isfinite(value); + } + + const double ratio = (time - t0) / (t1 - t0); + value = v0 + (v1 - v0) * ratio; + return true; + } + + // ============================ TabConfig ============================ + + TabConfig::TabConfig() + : m_name(L"视图 1") + { + } + + void TabConfig::EnsureStyle(const DataSet& dataSet, int fileIndex, int signalIndex) + { + (void)dataSet; + if (fileIndex < 0 || signalIndex < 0) + { + return; + } + + const std::size_t fileSlot = static_cast(fileIndex); + if (fileSlot >= m_styles.size()) + { + return; + } + std::vector& fileStyles = m_styles[fileSlot]; + if (static_cast(signalIndex) >= fileStyles.size()) + { + return; + } + + // 若样式仍为默认状态且颜色为默认色,则按全局序号重新取色。 + SignalStyle& style = fileStyles[static_cast(signalIndex)]; + + std::size_t globalIndex = 0; + for (std::size_t index = 0; index < fileSlot; ++index) + { + if (index < m_styles.size()) + { + globalIndex += m_styles[index].size(); + } + } + globalIndex += static_cast(signalIndex); + + style.color = PaletteColor(globalIndex); + style.lineWidth = static_cast(kDefaultLineWidth); + } + + void TabConfig::SyncToDataSet(const DataSet& dataSet) + { + const std::size_t fileCount = dataSet.FileCount(); + m_styles.resize(fileCount); + m_collapsed.resize(fileCount, false); + + for (std::size_t fileIndex = 0; fileIndex < fileCount; ++fileIndex) + { + const DataFile* file = dataSet.FileAt(static_cast(fileIndex)); + if (file == nullptr) + { + continue; + } + + std::vector& fileStyles = m_styles[fileIndex]; + const std::size_t previousSize = fileStyles.size(); + fileStyles.resize(file->signals.size()); + + // 新增信号补默认配置(默认不可见,颜色取自调色板)。 + for (std::size_t signalIndex = previousSize; signalIndex < file->signals.size(); ++signalIndex) + { + EnsureStyle(dataSet, static_cast(fileIndex), static_cast(signalIndex)); + } + } + + // 清理已失效的选中状态与网格引用。 + if (m_selected.IsValid() && dataSet.Resolve(m_selected) == nullptr) + { + m_selected = SignalRef(); + } + + for (Lane& lane : m_lanes) + { + std::vector& refs = lane.signals; + refs.erase(std::remove_if(refs.begin(), refs.end(), + [&dataSet](const SignalRef& ref) { return dataSet.Resolve(ref) == nullptr; }), + refs.end()); + } + m_lanes.erase(std::remove_if(m_lanes.begin(), m_lanes.end(), + [](const Lane& lane) { return lane.signals.empty(); }), + m_lanes.end()); + + SyncLanes(dataSet); + } + + void TabConfig::RemapAfterFileRemoval(int fileIndex) + { + if (fileIndex < 0) + { + return; + } + + const std::size_t slot = static_cast(fileIndex); + if (slot < m_styles.size()) + { + m_styles.erase(m_styles.begin() + fileIndex); + } + if (slot < m_collapsed.size()) + { + m_collapsed.erase(m_collapsed.begin() + fileIndex); + } + + for (Lane& lane : m_lanes) + { + std::vector& refs = lane.signals; + refs.erase(std::remove_if(refs.begin(), refs.end(), + [fileIndex](const SignalRef& ref) + { return ref.fileIndex == fileIndex; }), + refs.end()); + for (SignalRef& ref : refs) + { + if (ref.fileIndex > fileIndex) + { + --ref.fileIndex; + } + } + } + m_lanes.erase(std::remove_if(m_lanes.begin(), m_lanes.end(), + [](const Lane& lane) { return lane.signals.empty(); }), + m_lanes.end()); + + if (m_selected.fileIndex == fileIndex) + { + m_selected = SignalRef(); + } + else if (m_selected.fileIndex > fileIndex) + { + --m_selected.fileIndex; + } + } + + const SignalStyle* TabConfig::Style(const SignalRef& ref) const + { + if (ref.fileIndex < 0 || static_cast(ref.fileIndex) >= m_styles.size()) + { + return nullptr; + } + const std::vector& fileStyles = m_styles[static_cast(ref.fileIndex)]; + if (ref.signalIndex < 0 || static_cast(ref.signalIndex) >= fileStyles.size()) + { + return nullptr; + } + return &fileStyles[static_cast(ref.signalIndex)]; + } + + SignalStyle* TabConfig::Style(const SignalRef& ref) + { + return const_cast(static_cast(this)->Style(ref)); + } + + bool TabConfig::IsVisible(const SignalRef& ref) const + { + const SignalStyle* style = Style(ref); + return (style != nullptr) && style->visible; + } + + void TabConfig::SetVisible(const DataSet& dataSet, const SignalRef& ref, bool visible) + { + SignalStyle* style = Style(ref); + if (style == nullptr) + { + return; + } + if (style->visible == visible) + { + return; + } + style->visible = visible; + SyncLanes(dataSet); + } + + void TabConfig::ToggleVisible(const DataSet& dataSet, const SignalRef& ref) + { + const SignalStyle* style = Style(ref); + if (style == nullptr) + { + return; + } + SetVisible(dataSet, ref, !style->visible); + } + + void TabConfig::SetFileVisible(const DataSet& dataSet, int fileIndex, bool visible) + { + if (fileIndex < 0 || static_cast(fileIndex) >= m_styles.size()) + { + return; + } + for (SignalStyle& style : m_styles[static_cast(fileIndex)]) + { + style.visible = visible; + } + SyncLanes(dataSet); + } + + void TabConfig::SetAllVisible(const DataSet& dataSet, bool visible) + { + for (std::vector& fileStyles : m_styles) + { + for (SignalStyle& style : fileStyles) + { + style.visible = visible; + } + } + SyncLanes(dataSet); + } + + void TabConfig::SoloSignal(const DataSet& dataSet, const SignalRef& ref) + { + for (std::vector& fileStyles : m_styles) + { + for (SignalStyle& style : fileStyles) + { + style.visible = false; + } + } + if (SignalStyle* style = Style(ref); style != nullptr) + { + style->visible = true; + } + SyncLanes(dataSet); + } + + bool TabConfig::FileHasVisible(const DataSet& dataSet, int fileIndex) const + { + (void)dataSet; + if (fileIndex < 0 || static_cast(fileIndex) >= m_styles.size()) + { + return false; + } + for (const SignalStyle& style : m_styles[static_cast(fileIndex)]) + { + if (style.visible) + { + return true; + } + } + return false; + } + + bool TabConfig::FileAllVisible(const DataSet& dataSet, int fileIndex) const + { + (void)dataSet; + if (fileIndex < 0 || static_cast(fileIndex) >= m_styles.size()) + { + return false; + } + const std::vector& fileStyles = m_styles[static_cast(fileIndex)]; + if (fileStyles.empty()) + { + return false; + } + for (const SignalStyle& style : fileStyles) + { + if (!style.visible) + { + return false; + } + } + return true; + } + + bool TabConfig::AnyVisible() const + { + for (const std::vector& fileStyles : m_styles) + { + for (const SignalStyle& style : fileStyles) + { + if (style.visible) + { + return true; + } + } + } + return false; + } + + int TabConfig::VisibleCount() const + { + int count = 0; + for (const std::vector& fileStyles : m_styles) + { + for (const SignalStyle& style : fileStyles) + { + if (style.visible) + { + ++count; + } + } + } + return count; + } + + void TabConfig::SetMode(const DataSet& dataSet, LaneMode mode) + { + m_laneMode = mode; + SyncLanes(dataSet); + } + + void TabConfig::SyncLanes(const DataSet& dataSet) + { + (void)dataSet; + RebuildLanes(m_styles, m_lanes, m_laneMode); + } + + int TabConfig::LaneOfSignal(const SignalRef& ref) const + { + for (std::size_t lane = 0; lane < m_lanes.size(); ++lane) + { + const std::vector& refs = m_lanes[lane].signals; + if (std::find(refs.begin(), refs.end(), ref) != refs.end()) + { + return static_cast(lane); + } + } + return -1; + } + + int TabConfig::MoveSignalToLane(const DataSet& dataSet, const SignalRef& ref, int laneIndex) + { + if (dataSet.Resolve(ref) == nullptr) + { + return -1; + } + + // 手动指定归属意味着切换到自定义划分策略。 + m_laneMode = LaneMode::CUSTOM; + + for (Lane& lane : m_lanes) + { + std::vector& refs = lane.signals; + refs.erase(std::remove(refs.begin(), refs.end(), ref), refs.end()); + } + m_lanes.erase(std::remove_if(m_lanes.begin(), m_lanes.end(), + [](const Lane& lane) { return lane.signals.empty(); }), + m_lanes.end()); + + if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) + { + Lane lane; + lane.autoRange = true; + lane.signals.push_back(ref); + m_lanes.push_back(std::move(lane)); + } + else + { + m_lanes[static_cast(laneIndex)].signals.push_back(ref); + } + + for (std::size_t index = 0; index < m_lanes.size(); ++index) + { + m_lanes[index].name = L"网格 " + std::to_wstring(index + 1); + } + return LaneOfSignal(ref); + } + + bool TabConfig::IsCollapsed(int fileIndex) const + { + if (fileIndex < 0 || static_cast(fileIndex) >= m_collapsed.size()) + { + return false; + } + return m_collapsed[static_cast(fileIndex)]; + } + + void TabConfig::ToggleCollapsed(int fileIndex) + { + if (fileIndex < 0) + { + return; + } + if (static_cast(fileIndex) >= m_collapsed.size()) + { + m_collapsed.resize(static_cast(fileIndex) + 1, false); + } + m_collapsed[static_cast(fileIndex)] = !m_collapsed[static_cast(fileIndex)]; + } + + void TabConfig::ComputeLaneRange(const DataSet& dataSet, int laneIndex, double& yMin, double& yMax) const + { + if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) + { + yMin = 0.0; + yMax = 1.0; + return; + } + + const Lane& lane = m_lanes[static_cast(laneIndex)]; + if (!lane.autoRange) + { + yMin = lane.yMin; + yMax = lane.yMax; + if (!(yMax > yMin)) + { + yMin = 0.0; + yMax = 1.0; + } + return; + } + + double low = std::numeric_limits::infinity(); + double high = -std::numeric_limits::infinity(); + for (const SignalRef& ref : lane.signals) + { + if (!IsVisible(ref)) + { + continue; + } + const SignalData* signal = dataSet.Resolve(ref); + if (signal == nullptr || signal->finiteCount == 0) + { + continue; + } + low = std::min(low, signal->minValue); + high = std::max(high, signal->maxValue); + } + + if (!std::isfinite(low) || !std::isfinite(high)) + { + low = 0.0; + high = 1.0; + } + PadRange(low, high); + yMin = low; + yMax = high; + } + + bool TabConfig::ComputeVisibleTimeBounds(const DataSet& dataSet, double& minTime, double& maxTime) const + { + double low = std::numeric_limits::infinity(); + double high = -std::numeric_limits::infinity(); + + for (std::size_t fileIndex = 0; fileIndex < m_styles.size(); ++fileIndex) + { + bool fileUsed = false; + for (const SignalStyle& style : m_styles[fileIndex]) + { + if (style.visible) + { + fileUsed = true; + break; + } + } + if (!fileUsed) + { + continue; + } + + const DataFile* file = dataSet.FileAt(static_cast(fileIndex)); + if (file == nullptr) + { + continue; + } + if (std::isfinite(file->minTime)) + { + low = std::min(low, file->minTime); + } + if (std::isfinite(file->maxTime)) + { + high = std::max(high, file->maxTime); + } + } + + if (!std::isfinite(low) || !std::isfinite(high) || !(high > low)) + { + return false; + } + minTime = low; + maxTime = high; + return true; + } + +} // namespace jdi diff --git a/Utilities/Src/PaintUtil.cpp b/Utilities/Src/PaintUtil.cpp index 0ca623b..7c7131d 100644 --- a/Utilities/Src/PaintUtil.cpp +++ b/Utilities/Src/PaintUtil.cpp @@ -236,6 +236,28 @@ namespace jdi return static_cast(size.cx); } + float UiMeasureTextWidth(HFONT font, const std::wstring& text) + { + if (text.empty() || font == nullptr) + { + return 0.0f; + } + + HDC hdc = GetDC(nullptr); + if (hdc == nullptr) + { + return 0.0f; + } + + HGDIOBJ oldFont = SelectObject(hdc, font); + SIZE size = {}; + GetTextExtentPoint32W(hdc, text.c_str(), static_cast(text.size()), &size); + SelectObject(hdc, oldFont); + ReleaseDC(nullptr, hdc); + + return static_cast(size.cx); + } + std::wstring UiEllipsize(gd::Graphics& g, const std::wstring& text, HFONT font, float maxWidth) { if (text.empty() || font == nullptr || maxWidth <= 0.0f) diff --git a/Utilities/Src/SignalSet.cpp b/Utilities/Src/SignalSet.cpp deleted file mode 100644 index 675e79b..0000000 --- a/Utilities/Src/SignalSet.cpp +++ /dev/null @@ -1,612 +0,0 @@ -#include "SignalSet.hpp" - -#include "Theme.hpp" -#include "Utf8Util.hpp" - -#include -#include -#include -#include - -namespace jdi -{ - - namespace - { - const double kMissing = std::numeric_limits::quiet_NaN(); /**< 缺失值占位。 */ - const double kDefaultLineWidth = 1.6; /**< 默认线宽。 */ - - /** - * @brief 判断列名是否为时间轴候选列。 - * @param [IN] header 原始列名。 - * @return 可能为时间轴返回 true。 - */ - bool LooksLikeTimeHeader(const std::wstring& header) - { - const std::wstring text = ToLowerAscii(Trim(header)); - if (text.empty()) - { - return false; - } - - if (text == L"t" || text == L"time" || text == L"timestamp" || text == L"simulation time" || - text == L"时间" || text == L"时间轴") - { - return true; - } - return ContainsInsensitive(text, L"time") || ContainsInsensitive(text, L"时间"); - } - - /** - * @brief 剥离列名中的 "[n]" 序号前缀,得到显示名称。 - * @param [IN] header 原始列名。 - * @return 显示名称。 - */ - std::wstring MakeDisplayName(const std::wstring& header) - { - const std::wstring text = Trim(header); - if (text.size() >= 3 && text[0] == L'[') - { - const std::size_t close = text.find(L']'); - if (close != std::wstring::npos && close > 1 && (close + 1) < text.size()) - { - bool numeric = true; - for (std::size_t i = 1; i < close; ++i) - { - if (!iswdigit(text[i])) - { - numeric = false; - break; - } - } - if (numeric) - { - const std::wstring rest = Trim(text.substr(close + 1)); - if (!rest.empty()) - { - return rest; - } - } - } - } - return text.empty() ? header : text; - } - - /** - * @brief 从列名末尾的括号中提取单位(仅接受形如 mA / V / deg/s 的短标记)。 - * @param [IN] header 原始列名。 - * @return 单位文本,无法识别时返回空字符串。 - */ - std::wstring ExtractUnit(const std::wstring& header) - { - const std::wstring text = Trim(header); - if (text.size() < 3 || text.back() != L')') - { - return std::wstring(); - } - - const std::size_t open = text.rfind(L'('); - if (open == std::wstring::npos || open + 2 >= text.size()) - { - return std::wstring(); - } - - const std::wstring inner = Trim(text.substr(open + 1, text.size() - open - 2)); - if (inner.empty() || inner.size() > 10) - { - return std::wstring(); - } - - for (const wchar_t c : inner) - { - const bool allowed = iswalpha(c) || iswdigit(c) || c == L'%' || c == L'/' || c == L'^' || - c == L'-' || c == L'.' || c == L'\u00B0' || c == L'\u00B5' || - c == L'\u03A9' || c == L'\u00B2' || c == L'\u00B3' || c == L'\u00B7'; - if (!allowed) - { - return std::wstring(); - } - } - return inner; - } - - /** - * @brief 为网格范围补充留白,避免波形紧贴边界。 - * @param [IN, OUT] yMin 范围下限。 - * @param [IN, OUT] yMax 范围上限。 - */ - void PadRange(double& yMin, double& yMax) - { - if (!std::isfinite(yMin) || !std::isfinite(yMax)) - { - yMin = 0.0; - yMax = 1.0; - return; - } - - const double span = yMax - yMin; - if (span <= 0.0) - { - const double magnitude = std::max(1.0, std::fabs(yMin) * 0.1); - yMin -= magnitude; - yMax += magnitude; - return; - } - - const double pad = span * 0.06; - yMin -= pad; - yMax += pad; - } - } - - const wchar_t* DrawModeName(DrawMode mode) - { - switch (mode) - { - case DrawMode::LINE: - return L"线段"; - case DrawMode::SCATTER: - return L"散点"; - case DrawMode::LINE_SCATTER: - return L"点线"; - case DrawMode::STEP: - return L"阶梯"; - default: - return L"线段"; - } - } - - SignalSet::SignalSet() = default; - - void SignalSet::Clear() - { - m_time.clear(); - m_signals.clear(); - m_lanes.clear(); - m_timeColumnName.clear(); - m_filePath.clear(); - m_fileName.clear(); - m_minTime = 0.0; - m_maxTime = 0.0; - m_timeMonotonic = true; - m_selectedSignal = -1; - } - - bool SignalSet::BuildFromTable(const CsvTable& table, const std::wstring& filePath, std::wstring& error) - { - Clear(); - error.clear(); - - if (!table.HasData()) - { - error = L"数据表为空。"; - return false; - } - - const std::size_t columnCount = table.headers.size(); - const std::size_t rowCount = table.rows.size(); - - // 1. 识别时间轴列:优先取列名匹配的列,否则回退到第一列。 - int timeColumn = -1; - for (std::size_t column = 0; column < columnCount; ++column) - { - if (LooksLikeTimeHeader(table.headers[column])) - { - timeColumn = static_cast(column); - break; - } - } - if (timeColumn < 0) - { - timeColumn = 0; - } - - m_time.resize(rowCount); - std::size_t finiteTimeCount = 0; - bool monotonic = true; - double previousTime = 0.0; - bool hasPrevious = false; - - for (std::size_t row = 0; row < rowCount; ++row) - { - const double value = table.rows[row][static_cast(timeColumn)]; - m_time[row] = value; - if (std::isfinite(value)) - { - ++finiteTimeCount; - if (hasPrevious && value < previousTime) - { - monotonic = false; - } - previousTime = value; - hasPrevious = true; - } - } - - if (finiteTimeCount < 2) - { - error = L"时间轴列(" + table.headers[static_cast(timeColumn)] + L")有效数值不足。"; - return false; - } - - m_timeColumnName = table.headers[static_cast(timeColumn)]; - m_timeMonotonic = monotonic; - m_filePath = filePath; - - const std::size_t slashPos = filePath.find_last_of(L"\\/"); - m_fileName = (slashPos == std::wstring::npos) ? filePath : filePath.substr(slashPos + 1); - - // 2. 构建信号列。 - m_signals.reserve(columnCount); - for (std::size_t column = 0; column < columnCount; ++column) - { - if (static_cast(column) == timeColumn) - { - continue; - } - - Signal signal; - signal.rawHeader = table.headers[column]; - signal.name = MakeDisplayName(signal.rawHeader); - signal.unit = ExtractUnit(signal.rawHeader); - signal.color = PickDefaultColor(m_signals.size()); - signal.lineWidth = static_cast(kDefaultLineWidth); - signal.mode = DrawMode::LINE; - signal.visible = true; - - signal.values.resize(rowCount); - double minValue = std::numeric_limits::infinity(); - double maxValue = -std::numeric_limits::infinity(); - std::size_t finiteCount = 0; - - for (std::size_t row = 0; row < rowCount; ++row) - { - const double value = table.rows[row][column]; - signal.values[row] = value; - if (std::isfinite(value)) - { - ++finiteCount; - minValue = std::min(minValue, value); - maxValue = std::max(maxValue, value); - } - } - - if (finiteCount == 0) - { - signal.minValue = 0.0; - signal.maxValue = 0.0; - } - else - { - signal.minValue = minValue; - signal.maxValue = maxValue; - } - signal.finiteCount = finiteCount; - - m_signals.push_back(std::move(signal)); - } - - if (m_signals.empty()) - { - error = L"除时间轴外未发现任何数据列。"; - Clear(); - return false; - } - - // 3. 统计时间范围。 - double tMin = std::numeric_limits::infinity(); - double tMax = -std::numeric_limits::infinity(); - for (const double value : m_time) - { - if (std::isfinite(value)) - { - tMin = std::min(tMin, value); - tMax = std::max(tMax, value); - } - } - m_minTime = tMin; - m_maxTime = tMax; - - // 4. 默认每个信号独占一个网格,便于区分量纲差异较大的数据。 - m_lanes.clear(); - m_lanes.resize(m_signals.size()); - for (std::size_t index = 0; index < m_signals.size(); ++index) - { - m_lanes[index].name = L"网格 " + std::to_wstring(index + 1); - m_lanes[index].signalIndices.push_back(static_cast(index)); - m_lanes[index].autoRange = true; - } - - m_selectedSignal = m_signals.empty() ? -1 : 0; - return true; - } - - void SignalSet::SetSelectedSignal(int index) - { - if (index < 0 || index >= static_cast(m_signals.size())) - { - m_selectedSignal = -1; - return; - } - m_selectedSignal = index; - } - - int SignalSet::LaneOfSignal(int signalIndex) const - { - for (std::size_t lane = 0; lane < m_lanes.size(); ++lane) - { - const std::vector& indices = m_lanes[lane].signalIndices; - if (std::find(indices.begin(), indices.end(), signalIndex) != indices.end()) - { - return static_cast(lane); - } - } - return -1; - } - - int SignalSet::MoveSignalToLane(int signalIndex, int laneIndex) - { - if (signalIndex < 0 || signalIndex >= static_cast(m_signals.size())) - { - return -1; - } - - for (Lane& lane : m_lanes) - { - std::vector& indices = lane.signalIndices; - indices.erase(std::remove(indices.begin(), indices.end(), signalIndex), indices.end()); - } - - if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) - { - Lane lane; - lane.name = L"网格 " + std::to_wstring(m_lanes.size() + 1); - lane.autoRange = true; - lane.signalIndices.push_back(signalIndex); - m_lanes.push_back(std::move(lane)); - } - else - { - m_lanes[static_cast(laneIndex)].signalIndices.push_back(signalIndex); - } - - PruneEmptyLanes(); - return LaneOfSignal(signalIndex); - } - - void SignalSet::MergeIntoSingleLane() - { - m_lanes.clear(); - - Lane lane; - lane.name = L"网格 1"; - lane.autoRange = true; - lane.signalIndices.reserve(m_signals.size()); - for (std::size_t index = 0; index < m_signals.size(); ++index) - { - lane.signalIndices.push_back(static_cast(index)); - } - m_lanes.push_back(std::move(lane)); - } - - void SignalSet::SplitIntoLanes() - { - m_lanes.clear(); - m_lanes.resize(m_signals.size()); - for (std::size_t index = 0; index < m_signals.size(); ++index) - { - Lane& lane = m_lanes[index]; - lane.name = L"网格 " + std::to_wstring(index + 1); - lane.autoRange = true; - lane.signalIndices.push_back(static_cast(index)); - } - } - - void SignalSet::SetAllVisible(bool visible) - { - for (Signal& signal : m_signals) - { - signal.visible = visible; - } - } - - void SignalSet::SoloSignal(int signalIndex) - { - for (std::size_t index = 0; index < m_signals.size(); ++index) - { - m_signals[index].visible = (static_cast(index) == signalIndex); - } - } - - void SignalSet::ComputeLaneRange(int laneIndex, double& yMin, double& yMax) const - { - if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) - { - yMin = 0.0; - yMax = 1.0; - return; - } - - const Lane& lane = m_lanes[static_cast(laneIndex)]; - if (!lane.autoRange) - { - yMin = lane.yMin; - yMax = lane.yMax; - if (!(yMax > yMin)) - { - yMin = 0.0; - yMax = 1.0; - } - return; - } - - double low = std::numeric_limits::infinity(); - double high = -std::numeric_limits::infinity(); - for (const int signalIndex : lane.signalIndices) - { - if (signalIndex < 0 || signalIndex >= static_cast(m_signals.size())) - { - continue; - } - const Signal& signal = m_signals[static_cast(signalIndex)]; - if (!signal.visible || signal.finiteCount == 0) - { - continue; - } - low = std::min(low, signal.minValue); - high = std::max(high, signal.maxValue); - } - - if (!std::isfinite(low) || !std::isfinite(high)) - { - low = 0.0; - high = 1.0; - } - PadRange(low, high); - yMin = low; - yMax = high; - } - - int SignalSet::FindNearestSample(double time) const - { - const std::size_t count = m_time.size(); - if (count == 0) - { - return -1; - } - if (m_timeMonotonic) - { - const std::vector::const_iterator lower = - std::lower_bound(m_time.begin(), m_time.end(), time); - if (lower == m_time.begin()) - { - return 0; - } - if (lower == m_time.end()) - { - return static_cast(count - 1); - } - const std::size_t rightIndex = static_cast(lower - m_time.begin()); - const std::size_t leftIndex = rightIndex - 1; - const double leftDistance = std::fabs(time - m_time[leftIndex]); - const double rightDistance = std::fabs(m_time[rightIndex] - time); - return (leftDistance <= rightDistance) ? static_cast(leftIndex) : static_cast(rightIndex); - } - - int best = 0; - double bestDistance = std::numeric_limits::infinity(); - for (std::size_t index = 0; index < count; ++index) - { - if (!std::isfinite(m_time[index])) - { - continue; - } - const double distance = std::fabs(m_time[index] - time); - if (distance < bestDistance) - { - bestDistance = distance; - best = static_cast(index); - } - } - return best; - } - - bool SignalSet::SampleAt(int signalIndex, double time, bool interpolate, double& value) const - { - value = kMissing; - if (signalIndex < 0 || signalIndex >= static_cast(m_signals.size())) - { - return false; - } - if (m_time.empty() || time < m_minTime || time > m_maxTime) - { - return false; - } - - const std::vector& samples = m_signals[static_cast(signalIndex)].values; - - if (!m_timeMonotonic) - { - const int nearest = FindNearestSample(time); - if (nearest < 0) - { - return false; - } - value = samples[static_cast(nearest)]; - return std::isfinite(value); - } - - const std::vector::const_iterator lower = - std::lower_bound(m_time.begin(), m_time.end(), time); - const std::size_t rightIndex = static_cast(lower - m_time.begin()); - if (rightIndex == 0) - { - value = samples[0]; - return std::isfinite(value); - } - if (rightIndex >= m_time.size()) - { - value = samples.back(); - return std::isfinite(value); - } - - const std::size_t leftIndex = rightIndex - 1; - const double t0 = m_time[leftIndex]; - const double t1 = m_time[rightIndex]; - const double v0 = samples[leftIndex]; - const double v1 = samples[rightIndex]; - - if (!interpolate || !std::isfinite(v0) || !std::isfinite(v1) || !(t1 > t0)) - { - const double leftDistance = std::fabs(time - t0); - const double rightDistance = std::fabs(t1 - time); - value = (leftDistance <= rightDistance) ? v0 : v1; - return std::isfinite(value); - } - - const double ratio = (time - t0) / (t1 - t0); - value = v0 + (v1 - v0) * ratio; - return true; - } - - void SignalSet::PruneEmptyLanes() - { - if (m_lanes.size() <= 1) - { - return; - } - - std::vector kept; - kept.reserve(m_lanes.size()); - for (Lane& lane : m_lanes) - { - if (!lane.signalIndices.empty()) - { - kept.push_back(std::move(lane)); - } - } - - if (kept.empty()) - { - return; - } - - m_lanes = std::move(kept); - for (std::size_t index = 0; index < m_lanes.size(); ++index) - { - m_lanes[index].name = L"网格 " + std::to_wstring(index + 1); - } - } - - std::uint32_t SignalSet::PickDefaultColor(std::size_t index) const - { - int paletteCount = 0; - const std::uint32_t* palette = SignalPalette(paletteCount); - if (palette == nullptr || paletteCount <= 0) - { - return 0xFF268BD2; - } - return palette[index % static_cast(paletteCount)]; - } - -} // namespace jdi