feat: 支持多视图标签页、按文件分组的多文件信号管理与网格随勾选自动收敛

数据层与视图配置解耦:新增 DataSet(多文件只读数据层)与 TabConfig(每标签页独立配置),
以 SignalRef{fileIndex,signalIndex} 标识全局信号,替换原有单文件 SignalSet。

- 标签页:新增 TabBar 组件,支持新建/切换/关闭标签页;每页独立保存信号勾选、
  颜色/线宽/显示方式、网格划分、视图范围与时间光标,互不影响。
- 网格收敛:可见性变更统一经 TabConfig 的 Set*Visible 接口并同步重算网格,
  取消勾选后对应的空网格立即回收(修复此前留下空轨道的问题)。
- 多文件分组:左侧面板改为「文件分组 + 信号」树形列表,分组可折叠、可整组勾选,
  分组复选框支持全选/部分/未选三态;新加载文件的信号默认未勾选,由用户自行选择。
- 来源标识:信号行、样式编辑区标题、波形图例与光标读数在跨文件时标注来源文件名。
- 交互补充:面板滚动条支持点击跳转与拖拽,切换标签页自动滚动到已选信号。
- 工具栏精简为「打开 CSV/适应视图/放大/缩小/合并网格/每信号一格/全选信号/清空勾选」,
  按钮宽度改为按实际文本像素测量;打开对话框支持多选文件,拖放支持批量载入。
- 新增 TestData/[2].CSV 作为跨文件对比测试数据。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-09-29 15:48:04 +08:00
1 parent 132b305544
commit 9c6c374cce
21 files changed
+4066 -1441

No files matched your search

+84 -6
View File
@@ -6,11 +6,13 @@
#include <gdiplus.h> #include <gdiplus.h>
#include <string> #include <string>
#include <vector>
#include "DataModel.hpp"
#include "PaintUtil.hpp" #include "PaintUtil.hpp"
#include "PlotView.hpp" #include "PlotView.hpp"
#include "SignalPanel.hpp" #include "SignalPanel.hpp"
#include "SignalSet.hpp" #include "TabBar.hpp"
#include "Theme.hpp" #include "Theme.hpp"
#include "Toolbar.hpp" #include "Toolbar.hpp"
@@ -18,7 +20,10 @@ namespace jdi
{ {
/** /**
* @brief 应用主框架:负责窗口、布局、消息分发、绘制合成与命令处理。 * @brief 应用主框架:负责窗口、布局、消息分发、标签页管理与绘制合成。
*
* 数据层(DataSet)全局共享,标签页仅保存各自的视图配置(TabConfig),
* 因此新标签页可以独立勾选信号、独立配色与分组,互不影响。
*/ */
class Application class Application
{ {
@@ -54,6 +59,7 @@ namespace jdi
enum class Target : int enum class Target : int
{ {
NONE = 0, /**< 无目标。 */ NONE = 0, /**< 无目标。 */
TAB_BAR, /**< 标签栏。 */
TOOLBAR, /**< 工具栏。 */ TOOLBAR, /**< 工具栏。 */
PANEL, /**< 信号面板。 */ PANEL, /**< 信号面板。 */
PLOT, /**< 波形绘图区。 */ PLOT, /**< 波形绘图区。 */
@@ -122,6 +128,69 @@ namespace jdi
*/ */
void PaintStatusBar(gd::Graphics& g); 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 弹出文件选择对话框并加载所选文件。 * @brief 弹出文件选择对话框并加载所选文件。
*/ */
@@ -162,7 +231,7 @@ namespace jdi
/** /**
* @brief 解析命令行中的文件参数并加载。 * @brief 解析命令行中的文件参数并加载。
*/ */
void LoadCommandLineFile(); void LoadCommandLineFiles();
/** /**
* @brief 处理客户区鼠标移动。 * @brief 处理客户区鼠标移动。
@@ -182,18 +251,24 @@ namespace jdi
Theme m_theme; /**< 主题配色。 */ Theme m_theme; /**< 主题配色。 */
UiFonts m_fonts; /**< 界面字体。 */ UiFonts m_fonts; /**< 界面字体。 */
SignalSet m_document; /**< 已加载的数据集。 */ DataSet m_dataSet; /**< 全局共享的数据集合。 */
std::vector<TabConfig> m_tabs; /**< 标签页配置集合。 */
int m_activeTab = -1; /**< 当前激活标签页下标。 */
int m_tabNameCounter = 0; /**< 标签页命名计数器。 */
TabBar m_tabBar; /**< 顶部标签栏。 */
Toolbar m_toolbar; /**< 顶部工具栏。 */ Toolbar m_toolbar; /**< 顶部工具栏。 */
SignalPanel m_panel; /**< 左侧信号面板。 */ SignalPanel m_panel; /**< 左侧信号面板。 */
PlotView m_plot; /**< 波形绘图区。 */ PlotView m_plot; /**< 波形绘图区。 */
gd::RectF m_tabBarRect; /**< 标签栏区域。 */
gd::RectF m_toolbarRect; /**< 工具栏区域。 */ gd::RectF m_toolbarRect; /**< 工具栏区域。 */
gd::RectF m_panelRect; /**< 信号面板区域。 */ gd::RectF m_panelRect; /**< 信号面板区域。 */
gd::RectF m_splitterRect; /**< 面板分隔条区域。 */ gd::RectF m_splitterRect; /**< 面板分隔条区域。 */
gd::RectF m_plotRect; /**< 绘图区区域。 */ gd::RectF m_plotRect; /**< 绘图区区域。 */
gd::RectF m_statusRect; /**< 状态栏区域。 */ gd::RectF m_statusRect; /**< 状态栏区域。 */
float m_panelWidth = 292.0f; /**< 面板宽度(逻辑像素)。 */ float m_panelWidth = 300.0f; /**< 面板宽度(逻辑像素)。 */
bool m_draggingSplitter = false; /**< 是否正在拖动分隔条。 */ bool m_draggingSplitter = false; /**< 是否正在拖动分隔条。 */
int m_splitterGrabOffset = 0; /**< 分隔条拖动的抓取偏移。 */ int m_splitterGrabOffset = 0; /**< 分隔条拖动的抓取偏移。 */
@@ -205,9 +280,12 @@ namespace jdi
Target m_activeTarget = Target::NONE; /**< 当前鼠标作用目标。 */ Target m_activeTarget = Target::NONE; /**< 当前鼠标作用目标。 */
bool m_trackingLeave = false; /**< 是否已注册鼠标离开通知。 */ bool m_trackingLeave = false; /**< 是否已注册鼠标离开通知。 */
std::size_t m_lastLaneCount = 0; /**< 上次渲染时的网格数量,用于检测布局失效。 */
std::wstring m_statusMessage; /**< 状态栏提示文本。 */ std::wstring m_statusMessage; /**< 状态栏提示文本。 */
bool m_statusIsError = false; /**< 状态提示是否为错误。 */ bool m_statusIsError = false; /**< 状态提示是否为错误。 */
int m_cachedVisibleCount = -1; /**< 上次同步时的可见信号数量。 */
std::size_t m_cachedLaneCount = 0; /**< 上次同步时的网格数量。 */
bool m_cachedAnyVisible = false; /**< 上次同步时是否存在可见信号。 */
}; };
} // namespace jdi } // namespace jdi
+490 -106
View File
@@ -27,12 +27,18 @@ namespace jdi
const wchar_t* kWindowTitle = L"Just DataInspector"; /**< 默认窗口标题。 */ const wchar_t* kWindowTitle = L"Just DataInspector"; /**< 默认窗口标题。 */
const float kStatusBarHeight = 26.0f; /**< 状态栏高度(逻辑像素)。 */ const float kStatusBarHeight = 26.0f; /**< 状态栏高度(逻辑像素)。 */
const float kSplitterWidth = 4.0f; /**< 分隔条宽度(逻辑像素)。 */ const float kSplitterWidth = 4.0f; /**< 分隔条宽度(逻辑像素)。 */
const float kMinPanelWidth = 210.0f; /**< 面板最小宽度(逻辑像素)。 */ const float kMinPanelWidth = 220.0f; /**< 面板最小宽度(逻辑像素)。 */
const float kMaxPanelWidth = 460.0f; /**< 面板最大宽度(逻辑像素)。 */ const float kMaxPanelWidth = 480.0f; /**< 面板最大宽度(逻辑像素)。 */
const int kMinClientWidth = 820; /**< 窗口最小客户区宽度。 */ const int kMinClientWidth = 860; /**< 窗口最小客户区宽度。 */
const int kMinClientHeight = 520; /**< 窗口最小客户区高度。 */ const int kMinClientHeight = 540; /**< 窗口最小客户区高度。 */
const UINT kMessageRemoveFile = WM_APP + 1; /**< 面板请求移除文件的自定义消息。 */
const wchar_t* kStatusHint = L"滚轮缩放 · Shift+滚轮平移 · Ctrl+滚轮调幅值 · 右键框选 · 双击自适应"; 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)。 * @brief 开启进程级 DPI 感知(优先 Per-Monitor V2)。
*/ */
@@ -45,8 +51,7 @@ namespace jdi
SetProcessDpiAwarenessContextFn setContext = SetProcessDpiAwarenessContextFn setContext =
reinterpret_cast<SetProcessDpiAwarenessContextFn>( reinterpret_cast<SetProcessDpiAwarenessContextFn>(
reinterpret_cast<void*>(GetProcAddress(user32, "SetProcessDpiAwarenessContext"))); reinterpret_cast<void*>(GetProcAddress(user32, "SetProcessDpiAwarenessContext")));
if (setContext != nullptr && if (setContext != nullptr && setContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) != FALSE)
setContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) != FALSE)
{ {
return; return;
} }
@@ -166,14 +171,14 @@ namespace jdi
} }
// 依据工作区(排除任务栏)计算默认窗口尺寸并居中,避免窗口超出屏幕。 // 依据工作区(排除任务栏)计算默认窗口尺寸并居中,避免窗口超出屏幕。
RECT workArea = {0, 0, static_cast<LONG>(ScaleOf(1340.0f, m_scale)), RECT workArea = {0, 0, static_cast<LONG>(ScaleOf(1380.0f, m_scale)),
static_cast<LONG>(ScaleOf(860.0f, m_scale))}; static_cast<LONG>(ScaleOf(880.0f, m_scale))};
SystemParametersInfoW(SPI_GETWORKAREA, 0, &workArea, 0); SystemParametersInfoW(SPI_GETWORKAREA, 0, &workArea, 0);
const int workWidth = workArea.right - workArea.left; const int workWidth = workArea.right - workArea.left;
const int workHeight = workArea.bottom - workArea.top; const int workHeight = workArea.bottom - workArea.top;
const int windowWidth = std::min(static_cast<int>(ScaleOf(1340.0f, m_scale)), workWidth - 24); const int windowWidth = std::min(static_cast<int>(ScaleOf(1380.0f, m_scale)), workWidth - 24);
const int windowHeight = std::min(static_cast<int>(ScaleOf(860.0f, m_scale)), workHeight - 24); const int windowHeight = std::min(static_cast<int>(ScaleOf(880.0f, m_scale)), workHeight - 24);
const int windowX = workArea.left + std::max(0, (workWidth - windowWidth) / 2); const int windowX = workArea.left + std::max(0, (workWidth - windowWidth) / 2);
const int windowY = workArea.top + std::max(0, (workHeight - windowHeight) / 2); const int windowY = workArea.top + std::max(0, (workHeight - windowHeight) / 2);
@@ -186,6 +191,8 @@ namespace jdi
return false; return false;
} }
m_tabBar.Setup(&m_fonts, &m_theme);
m_tabBar.SetScale(m_scale);
m_toolbar.Setup(&m_fonts, &m_theme); m_toolbar.Setup(&m_fonts, &m_theme);
m_toolbar.SetScale(m_scale); m_toolbar.SetScale(m_scale);
m_panel.Setup(m_hwnd, &m_fonts, &m_theme); m_panel.Setup(m_hwnd, &m_fonts, &m_theme);
@@ -197,10 +204,13 @@ namespace jdi
SendMessageW(m_hwnd, WM_SETICON, ICON_BIG, reinterpret_cast<LPARAM>(icon)); SendMessageW(m_hwnd, WM_SETICON, ICON_BIG, reinterpret_cast<LPARAM>(icon));
SendMessageW(m_hwnd, WM_SETICON, ICON_SMALL, reinterpret_cast<LPARAM>(icon)); SendMessageW(m_hwnd, WM_SETICON, ICON_SMALL, reinterpret_cast<LPARAM>(icon));
// 创建首个标签页。
NewTab();
Relayout(); Relayout();
ShowWindow(m_hwnd, showCommand); ShowWindow(m_hwnd, showCommand);
UpdateWindow(m_hwnd); UpdateWindow(m_hwnd);
LoadCommandLineFile(); LoadCommandLineFiles();
return true; return true;
} }
@@ -227,6 +237,7 @@ namespace jdi
m_scale = static_cast<float>(dpi) / 96.0f; m_scale = static_cast<float>(dpi) / 96.0f;
m_fonts.Create(dpi); m_fonts.Create(dpi);
m_tabBar.SetScale(m_scale);
m_toolbar.SetScale(m_scale); m_toolbar.SetScale(m_scale);
m_panel.SetScale(m_scale); m_panel.SetScale(m_scale);
m_plot.SetScale(m_scale); m_plot.SetScale(m_scale);
@@ -234,6 +245,179 @@ namespace jdi
Relayout(); Relayout();
} }
TabConfig& Application::ActiveTab()
{
static TabConfig fallback;
if (m_activeTab < 0 || m_activeTab >= static_cast<int>(m_tabs.size()))
{
return fallback;
}
return m_tabs[static_cast<std::size_t>(m_activeTab)];
}
const TabConfig& Application::ActiveTab() const
{
static const TabConfig fallback;
if (m_activeTab < 0 || m_activeTab >= static_cast<int>(m_tabs.size()))
{
return fallback;
}
return m_tabs[static_cast<std::size_t>(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<int>(m_tabs.size()) - 1);
}
void Application::CloseTab(int index)
{
if (index < 0 || index >= static_cast<int>(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<int>(m_tabs.size()))
{
next = static_cast<int>(m_tabs.size()) - 1;
}
SetActiveTab(next);
}
void Application::SetActiveTab(int index)
{
if (index < 0 || index >= static_cast<int>(m_tabs.size()))
{
return;
}
m_activeTab = index;
// 重新绑定上下文:面板与绘图区均指向同一数据层与当前标签页配置。
m_panel.SetContext(&m_dataSet, &m_tabs[static_cast<std::size_t>(m_activeTab)]);
m_plot.SetContext(&m_dataSet, &m_tabs[static_cast<std::size_t>(m_activeTab)]);
RefreshTabBar();
Relayout();
// 切换标签页后把该页已选中的信号滚动到可见位置。
m_panel.ScrollToSignal(m_tabs[static_cast<std::size_t>(m_activeTab)].Selected());
SynchronizeView(true);
UpdateWindowTitle();
InvalidateRect(m_hwnd, nullptr, TRUE);
}
void Application::RefreshTabBar()
{
std::vector<std::wstring> 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<std::size_t>(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() void Application::Relayout()
{ {
RECT client = {}; RECT client = {};
@@ -245,25 +429,29 @@ namespace jdi
return; return;
} }
const float tabBarHeight = m_tabBar.DesiredHeight();
const float toolbarHeight = m_toolbar.DesiredHeight(); const float toolbarHeight = m_toolbar.DesiredHeight();
const float statusHeight = ScaleOf(kStatusBarHeight, m_scale); const float statusHeight = ScaleOf(kStatusBarHeight, m_scale);
const float splitterWidth = ScaleOf(kSplitterWidth, 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); 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); float panelWidth = ScaleOf(m_panelWidth, m_scale);
const float maximum = std::min(ScaleOf(kMaxPanelWidth, m_scale), width * 0.45f); const float maximum = std::min(ScaleOf(kMaxPanelWidth, m_scale), width * 0.45f);
panelWidth = std::max(ScaleOf(kMinPanelWidth, m_scale), std::min(panelWidth, maximum)); panelWidth = std::max(ScaleOf(kMinPanelWidth, m_scale), std::min(panelWidth, maximum));
m_panelWidth = panelWidth / m_scale; m_panelWidth = panelWidth / m_scale;
m_panelRect = gd::RectF(0.0f, toolbarHeight, panelWidth, contentHeight); m_panelRect = gd::RectF(0.0f, contentTop, panelWidth, contentHeight);
m_splitterRect = gd::RectF(panelWidth, toolbarHeight, splitterWidth, contentHeight); m_splitterRect = gd::RectF(panelWidth, contentTop, splitterWidth, contentHeight);
m_plotRect = gd::RectF(panelWidth + splitterWidth, toolbarHeight, m_plotRect = gd::RectF(panelWidth + splitterWidth, contentTop,
std::max(0.0f, width - panelWidth - splitterWidth), contentHeight); std::max(0.0f, width - panelWidth - splitterWidth), contentHeight);
m_tabBar.Layout(m_tabBarRect);
m_toolbar.Layout(m_toolbarRect); m_toolbar.Layout(m_toolbarRect);
m_panel.Layout(m_panelRect); m_panel.Layout(m_panelRect);
m_plot.Layout(m_plotRect); m_plot.Layout(m_plotRect);
@@ -340,16 +528,11 @@ namespace jdi
return; return;
} }
// 网格数量变化(如合并/拆分/移动信号)会影响滚动条与绘图区宽度,需要重新布局。 // 任何交互(勾选、移动网格、切换标签页)之后都在此统一收敛视图状态。
const std::size_t laneCount = m_document.IsLoaded() ? m_document.Lanes().size() : 0; SynchronizeView(false);
if (laneCount != m_lastLaneCount)
{
m_lastLaneCount = laneCount;
Relayout();
}
m_toolbar.SetDocumentLoaded(m_document.IsLoaded()); m_toolbar.SetDocumentLoaded(!m_dataSet.Empty());
if (m_document.IsLoaded()) if (!m_dataSet.Empty())
{ {
m_toolbar.SetStatusText(L"视图 " + FormatValue(m_plot.ViewMinTime()) + L" ~ " + m_toolbar.SetStatusText(L"视图 " + FormatValue(m_plot.ViewMinTime()) + L" ~ " +
FormatValue(m_plot.ViewMaxTime())); FormatValue(m_plot.ViewMaxTime()));
@@ -368,6 +551,7 @@ namespace jdi
UiFillRect(g, gd::RectF(0.0f, 0.0f, static_cast<float>(width), static_cast<float>(height)), UiFillRect(g, gd::RectF(0.0f, 0.0f, static_cast<float>(width), static_cast<float>(height)),
m_theme.windowBg); m_theme.windowBg);
m_tabBar.Paint(g);
m_toolbar.Paint(g); m_toolbar.Paint(g);
m_panel.Paint(g); m_panel.Paint(g);
m_plot.Paint(g); m_plot.Paint(g);
@@ -395,19 +579,20 @@ namespace jdi
const float halfWidth = m_statusRect.Width * 0.5f; const float halfWidth = m_statusRect.Width * 0.5f;
std::wstring leftText; std::wstring leftText;
if (m_document.IsLoaded()) if (!m_dataSet.Empty())
{ {
leftText = m_document.FileName() + L" · " + const TabConfig& tab = ActiveTab();
FormatCount(static_cast<unsigned long long>(m_document.SampleCount())) + L" 采样点 · " + leftText = std::to_wstring(m_dataSet.FileCount()) + L" 个文件 · " +
std::to_wstring(m_document.Signals().size()) + L" 信号 · " + FormatCount(static_cast<unsigned long long>(m_dataSet.SignalCount())) + L" 条信号 · " +
std::to_wstring(m_document.Lanes().size()) + L" 网格"; std::to_wstring(tab.VisibleCount()) + L" 条已选 · " +
std::to_wstring(tab.Lanes().size()) + L" 个网格";
} }
else else
{ {
leftText = L"未加载数据 · 请点击「打开 CSV」或拖拽文件到窗口"; 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), gd::RectF(m_statusRect.X + padding, m_statusRect.Y, halfWidth - padding, m_statusRect.Height),
m_theme.text, TextAlign::LEFT); m_theme.text, TextAlign::LEFT);
@@ -418,13 +603,13 @@ namespace jdi
rightText = m_statusMessage; rightText = m_statusMessage;
rightColor = m_statusIsError ? m_theme.danger : m_theme.textStrong; 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()); rightText = L"光标 · " + FormatValue(m_plot.CursorTime());
rightColor = m_theme.text; 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), gd::RectF(m_statusRect.X + halfWidth, m_statusRect.Y, halfWidth - padding, m_statusRect.Height),
rightColor, TextAlign::RIGHT); rightColor, TextAlign::RIGHT);
} }
@@ -438,75 +623,123 @@ namespace jdi
void Application::UpdateWindowTitle() void Application::UpdateWindowTitle()
{ {
std::wstring title = kWindowTitle; std::wstring title = kWindowTitle;
if (m_document.IsLoaded()) if (m_activeTab >= 0 && m_activeTab < static_cast<int>(m_tabs.size()))
{ {
title += L" — "; title += L" — ";
title += m_document.FileName(); title += m_tabs[static_cast<std::size_t>(m_activeTab)].Name();
}
if (!m_dataSet.Empty())
{
title += L"(" + std::to_wstring(m_dataSet.FileCount()) + L" 个文件)";
} }
SetWindowTextW(m_hwnd, title.c_str()); SetWindowTextW(m_hwnd, title.c_str());
} }
void Application::OpenFileDialog() void Application::OpenFileDialog()
{ {
wchar_t buffer[MAX_PATH * 4] = {}; // 允许一次选择多个文件,便于跨文件对比信号。
std::vector<wchar_t> buffer(static_cast<std::size_t>(MAX_PATH) * 16, L'\0');
OPENFILENAMEW dialog = {}; OPENFILENAMEW dialog = {};
dialog.lStructSize = sizeof(dialog); dialog.lStructSize = sizeof(dialog);
dialog.hwndOwner = m_hwnd; dialog.hwndOwner = m_hwnd;
dialog.lpstrFilter = L"CSV 数据文件 (*.csv)\0*.csv\0文本数据文件 (*.txt)\0*.txt\0所有文件 (*.*)\0*.*\0\0"; dialog.lpstrFilter = L"CSV 数据文件 (*.csv)\0*.csv\0文本数据文件 (*.txt)\0*.txt\0所有文件 (*.*)\0*.*\0\0";
dialog.lpstrFile = buffer; dialog.lpstrFile = buffer.data();
dialog.nMaxFile = MAX_PATH * 4; dialog.nMaxFile = static_cast<DWORD>(buffer.size());
dialog.lpstrTitle = L"打开仿真数据文件"; dialog.lpstrTitle = L"打开仿真数据文件(可多选)";
dialog.lpstrDefExt = L"csv"; 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) if (GetOpenFileNameW(&dialog) == FALSE)
{ {
return; 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) 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; CsvTable table;
std::wstring error; std::wstring error;
if (!LoadCsvTable(path, table, error)) if (!LoadCsvTable(path, table, error))
{ {
SetStatusMessage(L"解析失败:" + error, true); SetStatusMessage(L"解析失败:" + error, true);
InvalidateRect(m_hwnd, nullptr, FALSE);
return false; return false;
} }
SignalSet loaded; int fileIndex = -1;
if (!loaded.BuildFromTable(table, path, error)) if (!m_dataSet.AddFromTable(table, path, error, fileIndex))
{ {
SetStatusMessage(L"解析失败:" + error, true); SetStatusMessage(L"解析失败:" + error, true);
InvalidateRect(m_hwnd, nullptr, FALSE);
return false; return false;
} }
m_document = std::move(loaded); // 新文件的信号在全部标签页中默认均为未勾选状态,由用户自行决定查看内容。
m_panel.SetDocument(&m_document); SyncTabsToDataSet();
m_plot.SetDocument(&m_document); UpdateWindowTitle();
m_plot.FitAll();
Relayout();
std::wstring message = L"已加载 " + m_document.FileName() + L"(" + const DataFile* file = m_dataSet.FileAt(fileIndex);
FormatCount(static_cast<unsigned long long>(m_document.SampleCount())) + if (file != nullptr)
L" 行采样,时间列:" + m_document.TimeColumnName() + L")"; {
if (!m_document.TimeMonotonic()) std::wstring message = L"已加载 " + file->fileName + L"(" +
FormatCount(static_cast<unsigned long long>(file->time.size())) +
L" 行采样," + std::to_wstring(file->signals.size()) +
L" 条信号,时间列:" + file->timeColumnName + L")";
if (!file->timeMonotonic)
{ {
message += L" 提示:时间列非单调递增,光标取值使用最近邻策略。"; message += L" 提示:时间列非单调递增,光标取值使用最近邻策略。";
} }
SetStatusMessage(message, false); SetStatusMessage(message, false);
}
UpdateWindowTitle();
InvalidateRect(m_hwnd, nullptr, TRUE);
return true; return true;
} }
void Application::LoadCommandLineFile() void Application::LoadCommandLineFiles()
{ {
int argumentCount = 0; int argumentCount = 0;
LPWSTR* arguments = CommandLineToArgvW(GetCommandLineW(), &argumentCount); LPWSTR* arguments = CommandLineToArgvW(GetCommandLineW(), &argumentCount);
@@ -515,6 +748,7 @@ namespace jdi
return; return;
} }
int loaded = 0;
for (int index = 1; index < argumentCount; ++index) for (int index = 1; index < argumentCount; ++index)
{ {
const std::wstring argument = arguments[index]; const std::wstring argument = arguments[index];
@@ -522,10 +756,19 @@ namespace jdi
{ {
continue; continue;
} }
LoadFile(argument); if (LoadFile(argument))
break; {
++loaded;
}
} }
LocalFree(arguments); LocalFree(arguments);
if (loaded > 0)
{
SetStatusMessage(L"已加载 " + std::to_wstring(loaded) +
L" 个文件,请在左侧勾选需要查看的信号。",
false);
}
} }
void Application::ExecuteToolbarAction(ToolbarAction action) void Application::ExecuteToolbarAction(ToolbarAction action)
@@ -538,9 +781,6 @@ namespace jdi
case ToolbarAction::FIT_ALL: case ToolbarAction::FIT_ALL:
m_plot.FitAll(); m_plot.FitAll();
break; break;
case ToolbarAction::FIT_TIME:
m_plot.FitTime();
break;
case ToolbarAction::ZOOM_IN: case ToolbarAction::ZOOM_IN:
m_plot.ZoomTimeStep(1.4); m_plot.ZoomTimeStep(1.4);
break; break;
@@ -548,34 +788,45 @@ namespace jdi
m_plot.ZoomTimeStep(1.0 / 1.4); m_plot.ZoomTimeStep(1.0 / 1.4);
break; break;
case ToolbarAction::MERGE_LANES: case ToolbarAction::MERGE_LANES:
if (m_document.IsLoaded()) if (!m_dataSet.Empty())
{ {
m_document.MergeIntoSingleLane(); ActiveTab().SetMode(m_dataSet, LaneMode::MERGED);
m_panel.SetDocument(&m_document); SetStatusMessage(L"已将当前标签页的全部可见信号合并到同一网格。", false);
m_plot.FitAll();
Relayout();
SetStatusMessage(L"已将全部信号合并到同一网格。", false);
} }
break; break;
case ToolbarAction::SPLIT_LANES: case ToolbarAction::SPLIT_LANES:
if (m_document.IsLoaded()) if (!m_dataSet.Empty())
{ {
m_document.SplitIntoLanes(); ActiveTab().SetMode(m_dataSet, LaneMode::PER_SIGNAL);
m_panel.SetDocument(&m_document); SetStatusMessage(L"当前标签页已切换为「每信号一格」。", false);
m_plot.FitAll();
Relayout();
SetStatusMessage(L"已按信号拆分为独立网格。", false);
} }
break; break;
case ToolbarAction::RESET_VIEW: case ToolbarAction::SELECT_ALL:
m_plot.FitAll(); 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; break;
case ToolbarAction::NONE: case ToolbarAction::NONE:
default: default:
break; break;
} }
if (m_hwnd != nullptr)
{
InvalidateRect(m_hwnd, nullptr, TRUE); InvalidateRect(m_hwnd, nullptr, TRUE);
} }
}
bool Application::HandleKeyDown(WPARAM key) bool Application::HandleKeyDown(WPARAM key)
{ {
@@ -586,6 +837,26 @@ namespace jdi
OpenFileDialog(); OpenFileDialog();
return true; 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<int>(m_tabs.size());
SetActiveTab((m_activeTab + (backward ? count - 1 : 1)) % count);
}
return true;
}
switch (key) switch (key)
{ {
@@ -621,11 +892,6 @@ namespace jdi
m_plot.FitAll(); m_plot.FitAll();
return true; return true;
} }
if (key == L'G')
{
m_plot.FitTime();
return true;
}
return false; return false;
} }
@@ -639,8 +905,8 @@ namespace jdi
if (m_draggingSplitter && m_scale > 0.0f) if (m_draggingSplitter && m_scale > 0.0f)
{ {
const float desired = static_cast<float>(x - m_splitterGrabOffset) / m_scale; const float desired = static_cast<float>(x - m_splitterGrabOffset) / m_scale;
const float maximum = std::min(kMaxPanelWidth, const float maximum =
static_cast<float>(kMinClientWidth) / m_scale); std::min(kMaxPanelWidth, static_cast<float>(kMinClientWidth) / m_scale);
m_panelWidth = std::max(kMinPanelWidth, std::min(desired, maximum)); m_panelWidth = std::max(kMinPanelWidth, std::min(desired, maximum));
Relayout(); Relayout();
return true; return true;
@@ -652,6 +918,8 @@ namespace jdi
return m_panel.OnMouseMove(x, y); return m_panel.OnMouseMove(x, y);
case Target::TOOLBAR: case Target::TOOLBAR:
return m_toolbar.OnMouseMove(x, y); return m_toolbar.OnMouseMove(x, y);
case Target::TAB_BAR:
return m_tabBar.OnMouseMove(x, y);
case Target::NONE: case Target::NONE:
default: default:
return false; return false;
@@ -659,12 +927,84 @@ namespace jdi
} }
bool changed = false; 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_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; changed = (m_plot.HitTest(x, y) ? m_plot.OnMouseMove(x, y) : m_plot.OnMouseLeave()) || changed;
return 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<int>(m_tabs.size()))
{
return;
}
HMENU menu = CreatePopupMenu();
if (menu == nullptr)
{
return;
}
AppendMenuW(menu, MF_STRING, static_cast<UINT_PTR>(kMenuTabNew), L"新建标签页");
AppendMenuW(menu, MF_STRING, static_cast<UINT_PTR>(kMenuTabClose), L"关闭该标签页");
AppendMenuW(menu, MF_STRING, static_cast<UINT_PTR>(kMenuTabCloseOthers), L"关闭其它标签页");
if (m_tabs.size() <= 1)
{
EnableMenuItem(menu, static_cast<UINT>(kMenuTabClose), MF_BYCOMMAND | MF_GRAYED);
EnableMenuItem(menu, static_cast<UINT>(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<std::size_t>(result.index)];
m_tabs.clear();
m_tabs.push_back(keep);
SetActiveTab(0);
}
}
LRESULT Application::HandleMessage(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) LRESULT Application::HandleMessage(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam)
{ {
switch (message) switch (message)
@@ -731,7 +1071,8 @@ namespace jdi
case WM_MOUSELEAVE: case WM_MOUSELEAVE:
{ {
m_trackingLeave = false; 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_panel.OnMouseLeave() || changed;
changed = m_plot.OnMouseLeave() || changed; changed = m_plot.OnMouseLeave() || changed;
if (changed) if (changed)
@@ -755,6 +1096,15 @@ namespace jdi
return 0; 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)) if (m_toolbar.HitTest(x, y))
{ {
SetCapture(hwnd); SetCapture(hwnd);
@@ -771,7 +1121,7 @@ namespace jdi
m_activeTarget = Target::PANEL; m_activeTarget = Target::PANEL;
if (m_panel.OnLButtonDown(x, y)) if (m_panel.OnLButtonDown(x, y))
{ {
InvalidateRect(hwnd, nullptr, FALSE); InvalidateRect(hwnd, nullptr, TRUE);
} }
return 0; return 0;
} }
@@ -796,19 +1146,23 @@ namespace jdi
const int y = GET_Y_LPARAM(lParam); const int y = GET_Y_LPARAM(lParam);
ReleaseCapture(); ReleaseCapture();
bool changed = false;
switch (m_activeTarget) switch (m_activeTarget)
{ {
case Target::SPLITTER: case Target::SPLITTER:
m_draggingSplitter = false; m_draggingSplitter = false;
break; break;
case Target::TAB_BAR:
{
TabBarResult result;
m_tabBar.OnLButtonUp(x, y, result);
m_activeTarget = Target::NONE;
OnTabBarResult(result);
return 0;
}
case Target::TOOLBAR: case Target::TOOLBAR:
{ {
ToolbarAction action = ToolbarAction::NONE; ToolbarAction action = ToolbarAction::NONE;
if (m_toolbar.OnLButtonUp(x, y, action)) m_toolbar.OnLButtonUp(x, y, action);
{
changed = true;
}
m_activeTarget = Target::NONE; m_activeTarget = Target::NONE;
if (action != ToolbarAction::NONE) if (action != ToolbarAction::NONE)
{ {
@@ -818,10 +1172,10 @@ namespace jdi
break; break;
} }
case Target::PANEL: case Target::PANEL:
changed = m_panel.OnLButtonUp(x, y) || changed; m_panel.OnLButtonUp(x, y);
break; break;
case Target::PLOT: case Target::PLOT:
changed = m_plot.OnLButtonUp(x, y) || changed; m_plot.OnLButtonUp(x, y);
break; break;
case Target::NONE: case Target::NONE:
default: default:
@@ -829,11 +1183,7 @@ namespace jdi
} }
m_activeTarget = Target::NONE; m_activeTarget = Target::NONE;
changed = true; InvalidateRect(hwnd, nullptr, TRUE);
if (changed)
{
InvalidateRect(hwnd, nullptr, FALSE);
}
return 0; return 0;
} }
case WM_LBUTTONDBLCLK: case WM_LBUTTONDBLCLK:
@@ -851,7 +1201,7 @@ namespace jdi
} }
if (changed) if (changed)
{ {
InvalidateRect(hwnd, nullptr, FALSE); InvalidateRect(hwnd, nullptr, TRUE);
} }
return 0; return 0;
} }
@@ -860,13 +1210,26 @@ namespace jdi
const int x = GET_X_LPARAM(lParam); const int x = GET_X_LPARAM(lParam);
const int y = GET_Y_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)) if (m_panel.HitTest(x, y))
{ {
SetCapture(hwnd); SetCapture(hwnd);
m_activeTarget = Target::PANEL; m_activeTarget = Target::PANEL;
if (m_panel.OnRButtonDown(x, y)) if (m_panel.OnRButtonDown(x, y))
{ {
InvalidateRect(hwnd, nullptr, FALSE); InvalidateRect(hwnd, nullptr, TRUE);
} }
ReleaseCapture(); ReleaseCapture();
m_activeTarget = Target::NONE; m_activeTarget = Target::NONE;
@@ -955,23 +1318,44 @@ namespace jdi
case WM_KEYDOWN: case WM_KEYDOWN:
if (HandleKeyDown(wParam)) if (HandleKeyDown(wParam))
{ {
InvalidateRect(hwnd, nullptr, FALSE); InvalidateRect(hwnd, nullptr, TRUE);
return 0; return 0;
} }
break; break;
case kMessageRemoveFile:
{
RemoveFile(static_cast<int>(wParam));
return 0;
}
case WM_DROPFILES: case WM_DROPFILES:
{ {
HDROP drop = reinterpret_cast<HDROP>(wParam); HDROP drop = reinterpret_cast<HDROP>(wParam);
const UINT length = DragQueryFileW(drop, 0, nullptr, 0); const UINT count = DragQueryFileW(drop, 0xFFFFFFFF, nullptr, 0);
if (length > 0) int loaded = 0;
for (UINT index = 0; index < count; ++index)
{ {
const UINT length = DragQueryFileW(drop, index, nullptr, 0);
if (length == 0)
{
continue;
}
std::vector<wchar_t> buffer(static_cast<std::size_t>(length) + 1, L'\0'); std::vector<wchar_t> buffer(static_cast<std::size_t>(length) + 1, L'\0');
if (DragQueryFileW(drop, 0, buffer.data(), length + 1) > 0) if (DragQueryFileW(drop, index, buffer.data(), length + 1) > 0)
{ {
LoadFile(std::wstring(buffer.data())); if (LoadFile(std::wstring(buffer.data())))
{
++loaded;
}
} }
} }
DragFinish(drop); DragFinish(drop);
if (loaded > 0)
{
SetStatusMessage(L"已加载 " + std::to_wstring(loaded) +
L" 个文件,请在左侧勾选需要查看的信号。",
false);
}
return 0; return 0;
} }
case WM_DESTROY: case WM_DESTROY:
+80 -36
View File
@@ -8,8 +8,8 @@
#include <string> #include <string>
#include <vector> #include <vector>
#include "DataModel.hpp"
#include "PaintUtil.hpp" #include "PaintUtil.hpp"
#include "SignalSet.hpp"
#include "Theme.hpp" #include "Theme.hpp"
namespace jdi namespace jdi
@@ -29,6 +29,9 @@ namespace jdi
/** /**
* @brief 波形绘图区:网格、时间轴、光标、缩放与自适应。 * @brief 波形绘图区:网格、时间轴、光标、缩放与自适应。
*
* 绘图区同时依赖数据层(DataSet,只读)与当前标签页配置(TabConfig,可写),
* 视图范围、网格滚动与时间光标均保存在标签页配置中,因此各标签页互不干扰。
*/ */
class PlotView class PlotView
{ {
@@ -43,10 +46,11 @@ namespace jdi
void Setup(const UiFonts* fonts, const Theme* theme); void Setup(const UiFonts* fonts, const Theme* theme);
/** /**
* @brief 绑定数据集。 * @brief 绑定数据层与当前标签页配置。
* @param [IN] document 数据集指针,允许为空。 * @param [IN] dataSet 数据集合,允许为空。
* @param [IN] tab 当前标签页配置,允许为空。
*/ */
void SetDocument(SignalSet* document); void SetContext(const DataSet* dataSet, TabConfig* tab);
/** /**
* @brief 设置 DPI 缩放比例。 * @brief 设置 DPI 缩放比例。
@@ -149,12 +153,7 @@ namespace jdi
HCURSOR QueryCursor(int x, int y) const; HCURSOR QueryCursor(int x, int y) const;
/** /**
* @brief 时间轴自适应到全部数据范围。 * @brief 时间轴与幅值轴同时自适应到可见信号范围。
*/
void FitTime();
/**
* @brief 时间轴与幅值轴同时自适应。
*/ */
void FitAll(); void FitAll();
@@ -180,7 +179,7 @@ namespace jdi
* @brief 获取网格当前内容总高度。 * @brief 获取网格当前内容总高度。
* @return 内容高度(像素)。 * @return 内容高度(像素)。
*/ */
float LaneContentHeight() const; float LaneContentHeight() const { return m_laneContentHeight; }
/** /**
* @brief 清除已固定的时间光标。 * @brief 清除已固定的时间光标。
@@ -191,31 +190,30 @@ namespace jdi
* @brief 判断当前是否存在激活的时间光标。 * @brief 判断当前是否存在激活的时间光标。
* @return 存在返回 true。 * @return 存在返回 true。
*/ */
bool HasCursor() const { return m_cursorVisible; } bool HasCursor() const;
/** /**
* @brief 获取当前激活光标对应的时间。 * @brief 获取当前激活光标对应的时间。
* @return 光标时间。 * @return 光标时间。
*/ */
double CursorTime() const { return m_cursorTime; } double CursorTime() const;
/** /**
* @brief 获取当前视图左边界时间。 * @brief 获取当前视图左边界时间。
* @return 视图左边界时间。 * @return 视图左边界时间。
*/ */
double ViewMinTime() const { return m_viewMin; } double ViewMinTime() const;
/** /**
* @brief 获取当前视图右边界时间。 * @brief 获取当前视图右边界时间。
* @return 视图右边界时间。 * @return 视图右边界时间。
*/ */
double ViewMaxTime() const { return m_viewMax; } double ViewMaxTime() const;
/** /**
* @brief 获取绘图区(波形区域)矩形。 * @brief 重新生成网格几何(可见性或网格划分变化后调用)。
* @return 波形区域矩形。
*/ */
const gd::RectF& WaveformRect() const { return m_plotRect; } void RefreshLanes();
private: private:
/** /**
@@ -226,9 +224,57 @@ namespace jdi
gd::RectF rect; /**< 网格波形区域。 */ gd::RectF rect; /**< 网格波形区域。 */
double yMin = 0.0; /**< 幅值下限。 */ double yMin = 0.0; /**< 幅值下限。 */
double yMax = 1.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 重新计算各网格的布局与幅值范围。 * @brief 重新计算各网格的布局与幅值范围。
*/ */
@@ -290,13 +336,13 @@ namespace jdi
/** /**
* @brief 构建单条信号在当前视图下的折线点序列。 * @brief 构建单条信号在当前视图下的折线点序列。
* @param [IN] signal 目标信号。 * @param [IN] resolved 已解析的信号视图。
* @param [IN] lane 所属网格绘制信息。 * @param [IN] lane 所属网格绘制信息。
* @param [IN] stepMode 是否使用阶梯保持连线。 * @param [IN] stepMode 是否使用阶梯保持连线。
* @param [OUT] points 输出点序列。 * @param [OUT] points 输出点序列。
* @param [OUT] breaks 输出每段折线的起始下标(用于处理缺失值断线)。 * @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<gd::PointF>& points, std::vector<std::size_t>& breaks) const; std::vector<gd::PointF>& points, std::vector<std::size_t>& breaks) const;
/** /**
@@ -307,8 +353,8 @@ namespace jdi
* @param [IN] timeTicks 时间刻度序列。 * @param [IN] timeTicks 时间刻度序列。
* @param [IN] timeStep 时间刻度步长。 * @param [IN] timeStep 时间刻度步长。
*/ */
void PaintLane(gd::Graphics& g, int laneIndex, const LaneVisual& lane, const std::vector<double>& timeTicks, void PaintLane(gd::Graphics& g, int laneIndex, const LaneVisual& lane,
double timeStep); const std::vector<double>& timeTicks, double timeStep);
/** /**
* @brief 绘制底部时间轴。 * @brief 绘制底部时间轴。
@@ -347,9 +393,17 @@ namespace jdi
*/ */
void PaintLaneReadout(gd::Graphics& g, int laneIndex, const LaneVisual& lane); 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 UiFonts* m_fonts = nullptr; /**< 字体资源。 */
const Theme* m_theme = 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 缩放比例。 */ float m_scale = 1.0f; /**< DPI 缩放比例。 */
gd::RectF m_area; /**< 绘图区整体区域。 */ gd::RectF m_area; /**< 绘图区整体区域。 */
@@ -357,21 +411,13 @@ namespace jdi
gd::RectF m_timeAxisRect; /**< 时间轴区域。 */ gd::RectF m_timeAxisRect; /**< 时间轴区域。 */
gd::RectF m_scrollBarRect; /**< 网格滚动条区域。 */ gd::RectF m_scrollBarRect; /**< 网格滚动条区域。 */
float m_yAxisWidth = 64.0f; /**< 纵轴标签列宽度(逻辑像素)。 */ float m_yAxisWidth = 74.0f; /**< 纵轴标签列宽度(逻辑像素)。 */
float m_timeAxisHeight = 34.0f; /**< 时间轴高度(逻辑像素)。 */ float m_timeAxisHeight = 32.0f; /**< 时间轴高度(逻辑像素)。 */
float m_laneGap = 6.0f; /**< 网格间距(逻辑像素)。 */ float m_laneGap = 6.0f; /**< 网格间距(逻辑像素)。 */
float m_minLaneHeight = 46.0f; /**< 网格最小高度(逻辑像素)。 */ float m_minLaneHeight = 46.0f; /**< 网格最小高度(逻辑像素)。 */
float m_laneScroll = 0.0f; /**< 网格纵向滚动偏移量。 */
float m_laneContentHeight = 0.0f; /**< 网格内容总高度。 */ float m_laneContentHeight = 0.0f; /**< 网格内容总高度。 */
bool m_scrollBarVisible = false; /**< 是否需要显示滚动条。 */ 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; /**< 当前拖拽模式。 */ PlotDragMode m_dragMode = PlotDragMode::NONE; /**< 当前拖拽模式。 */
int m_dragLane = -1; /**< 拖拽所作用的网格下标。 */ int m_dragLane = -1; /**< 拖拽所作用的网格下标。 */
bool m_dragMoved = false; /**< 拖拽过程中是否发生了位移。 */ bool m_dragMoved = false; /**< 拖拽过程中是否发生了位移。 */
@@ -379,8 +425,6 @@ namespace jdi
int m_dragStartY = 0; /**< 拖拽起点纵坐标。 */ int m_dragStartY = 0; /**< 拖拽起点纵坐标。 */
int m_dragLastX = 0; /**< 上一次拖拽横坐标。 */ int m_dragLastX = 0; /**< 上一次拖拽横坐标。 */
int m_dragLastY = 0; /**< 上一次拖拽纵坐标。 */ int m_dragLastY = 0; /**< 上一次拖拽纵坐标。 */
double m_dragStartViewMin = 0.0; /**< 拖拽起始视图左边界。 */
double m_dragStartViewMax = 1.0; /**< 拖拽起始视图右边界。 */
double m_dragAnchorValue = 0.0; /**< 纵轴缩放锚点幅值。 */ double m_dragAnchorValue = 0.0; /**< 纵轴缩放锚点幅值。 */
double m_dragAnchorRatio = 0.5; /**< 纵轴缩放锚点相对位置。 */ double m_dragAnchorRatio = 0.5; /**< 纵轴缩放锚点相对位置。 */
double m_dragStartYMin = 0.0; /**< 拖拽起始幅值下限。 */ double m_dragStartYMin = 0.0; /**< 拖拽起始幅值下限。 */
File diff suppressed because it is too large. Load diff
+137 -19
View File
@@ -7,16 +7,20 @@
#include <array> #include <array>
#include <string> #include <string>
#include <vector>
#include "DataModel.hpp"
#include "PaintUtil.hpp" #include "PaintUtil.hpp"
#include "SignalSet.hpp"
#include "Theme.hpp" #include "Theme.hpp"
namespace jdi namespace jdi
{ {
/** /**
* @brief 左侧信号面板:信号列表、可见性开关与样式编辑。 * @brief 左侧信号面板:按文件分组的信号树、可见性勾选与样式编辑。
*
* 面板只读取数据层(DataSet),全部显示配置读写均落在当前标签页(TabConfig)上,
* 因此不同标签页的信号勾选与样式互不影响。
*/ */
class SignalPanel class SignalPanel
{ {
@@ -32,10 +36,28 @@ namespace jdi
void Setup(HWND host, const UiFonts* fonts, const Theme* theme); void Setup(HWND host, const UiFonts* fonts, const Theme* theme);
/** /**
* @brief 绑定数据集。 * @brief 绑定数据层与当前标签页配置。
* @param [IN] document 数据集指针,允许为空。 * @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 缩放比例。 * @brief 设置 DPI 缩放比例。
@@ -88,7 +110,7 @@ namespace jdi
bool OnDoubleClick(int x, int y); bool OnDoubleClick(int x, int y);
/** /**
* @brief 处理鼠标右键按下(弹出信号上下文菜单)。 * @brief 处理鼠标右键按下(弹出上下文菜单)。
* @param [IN] x 客户区横坐标。 * @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。 * @param [IN] y 客户区纵坐标。
* @return 需要重绘返回 true。 * @return 需要重绘返回 true。
@@ -116,7 +138,33 @@ namespace jdi
*/ */
bool HitTest(int x, int y) const; bool HitTest(int x, int y) const;
/**
* @brief 将列表滚动到指定信号所在位置。
* @param [IN] ref 目标信号引用。
*/
void ScrollToSignal(const SignalRef& ref);
private: private:
/**
* @brief 列表行的类型。
*/
enum class RowKind : int
{
FILE = 0, /**< 文件分组标题行。 */
SIGNAL = 1 /**< 信号行。 */
};
/**
* @brief 行的可交互区域。
*/
enum class RowPart : int
{
NONE = 0, /**< 未命中。 */
DISCLOSURE, /**< 折叠三角。 */
CHECK, /**< 复选框。 */
BODY /**< 行主体。 */
};
/** /**
* @brief 样式编辑区中的可点击条目类型。 * @brief 样式编辑区中的可点击条目类型。
*/ */
@@ -131,8 +179,34 @@ namespace jdi
}; };
/** /**
* @brief 计算列表区域内容高度。 * @brief 列表中的一行(文件分组或信号)。
* @return 内容高度。 */
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; float ContentHeight() const;
@@ -142,11 +216,13 @@ namespace jdi
void ClampScroll(); void ClampScroll();
/** /**
* @brief 查询指定坐标对应的信号行下标。 * @brief 查询坐标命中的行下标与行内区域。
* @param [IN] x 客户区横坐标。
* @param [IN] y 客户区纵坐标。 * @param [IN] y 客户区纵坐标。
* @param [OUT] part 行内区域类型。
* @return 行下标,未命中返回 -1。 * @return 行下标,未命中返回 -1。
*/ */
int RowAtY(int y) const; int HitRow(int x, int y, RowPart& part);
/** /**
* @brief 计算行的纵向矩形。 * @brief 计算行的纵向矩形。
@@ -156,11 +232,18 @@ namespace jdi
gd::RectF RowRect(int row) const; gd::RectF RowRect(int row) const;
/** /**
* @brief 绘制列表区域的单行。 * @brief 绘制文件分组标题行。
* @param [IN,OUT] g 绘图上下文。 * @param [IN,OUT] g 绘图上下文。
* @param [IN] row 行下标。 * @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 绘制样式编辑区。 * @brief 绘制样式编辑区。
@@ -168,13 +251,33 @@ namespace jdi
*/ */
void PaintStyleSection(gd::Graphics& g); void PaintStyleSection(gd::Graphics& g);
/**
* @brief 绘制列表滚动条。
* @param [IN,OUT] g 绘图上下文。
*/
void PaintScrollBar(gd::Graphics& g);
/**
* @brief 计算滚动条滑块矩形。
* @return 滑块矩形,无需滚动时返回空矩形。
*/
gd::RectF ScrollThumbRect() const;
/** /**
* @brief 弹出信号右键菜单。 * @brief 弹出信号右键菜单。
* @param [IN] signalIndex 目标信号下标。 * @param [IN] ref 目标信号引用。
* @param [IN] screenX 屏幕坐标横坐标。 * @param [IN] screenX 屏幕坐标横坐标。
* @param [IN] screenY 屏幕坐标纵坐标。 * @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 弹出网格分配菜单。 * @brief 弹出网格分配菜单。
@@ -182,15 +285,22 @@ namespace jdi
void ShowLaneMenu(); void ShowLaneMenu();
/** /**
* @brief 判断全部信号是否均为可见状态。 * @brief 判断当前标签页中全部信号是否均为可见状态。
* @return 全部可见返回 true。 * @return 全部可见且至少有一条信号返回 true。
*/ */
bool AreAllSignalsVisible() const; bool AreAllSignalsVisible() const;
/**
* @brief 统计全部信号数量。
* @return 信号总数。
*/
int TotalSignalCount() const;
HWND m_host = nullptr; /**< 宿主窗口句柄。 */ HWND m_host = nullptr; /**< 宿主窗口句柄。 */
const UiFonts* m_fonts = nullptr; /**< 字体资源。 */ const UiFonts* m_fonts = nullptr; /**< 字体资源。 */
const Theme* m_theme = 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 缩放比例。 */ float m_scale = 1.0f; /**< DPI 缩放比例。 */
gd::RectF m_area; /**< 面板整体区域。 */ gd::RectF m_area; /**< 面板整体区域。 */
@@ -199,16 +309,24 @@ namespace jdi
gd::RectF m_styleRect; /**< 样式编辑区域。 */ gd::RectF m_styleRect; /**< 样式编辑区域。 */
gd::RectF m_toggleAllRect; /**< 全部显示/隐藏按钮。 */ gd::RectF m_toggleAllRect; /**< 全部显示/隐藏按钮。 */
gd::RectF m_laneButtonRect; /**< 网格选择按钮。 */ gd::RectF m_laneButtonRect; /**< 网格选择按钮。 */
gd::RectF m_scrollBarRect; /**< 滚动条区域。 */
std::array<gd::RectF, 12> m_colorRects; /**< 颜色色块矩形。 */ std::array<gd::RectF, 12> m_colorRects; /**< 颜色色块矩形。 */
std::array<gd::RectF, 4> m_widthRects; /**< 线宽按钮矩形。 */ std::array<gd::RectF, 4> m_widthRects; /**< 线宽按钮矩形。 */
std::array<gd::RectF, 4> m_modeRects; /**< 绘制方式按钮矩形。 */ std::array<gd::RectF, 4> m_modeRects; /**< 绘制方式按钮矩形。 */
int m_colorCount = 0; /**< 实际可用的颜色数量。 */ int m_colorCount = 0; /**< 实际可用的颜色数量。 */
float m_widthRowLabelX = 0.0f; /**< 样式标签列起始横坐标。 */
float m_styleRowHeight = 0.0f; /**< 样式行高。 */ std::vector<ListRow> 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; /**< 列表滚动偏移量。 */ float m_scrollOffset = 0.0f; /**< 列表滚动偏移量。 */
bool m_draggingScrollBar = false; /**< 是否正在拖拽滚动条。 */
int m_scrollGrabOffset = 0; /**< 拖拽时鼠标相对滑块顶部的偏移。 */
int m_hoverRow = -1; /**< 当前悬停行。 */ int m_hoverRow = -1; /**< 当前悬停行。 */
RowPart m_hoverPart = RowPart::NONE; /**< 当前悬停的行内区域。 */
StyleHit m_hoverStyle = StyleHit::NONE; /**< 当前悬停的样式条目类型。 */ StyleHit m_hoverStyle = StyleHit::NONE; /**< 当前悬停的样式条目类型。 */
int m_hoverStyleIndex = -1; /**< 当前悬停的样式条目下标。 */ int m_hoverStyleIndex = -1; /**< 当前悬停的样式条目下标。 */
}; };
File diff suppressed because it is too large. Load diff
+179
View File
@@ -0,0 +1,179 @@
#ifndef __TAB_BAR_HPP__
#define __TAB_BAR_HPP__
#include <windows.h>
#include <objidl.h>
#include <gdiplus.h>
#include <string>
#include <vector>
#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<std::wstring>& 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<TabItem> 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__
+307
View File
@@ -0,0 +1,307 @@
#include "TabBar.hpp"
#include <algorithm>
#include <cmath>
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<std::wstring>& 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<int>(m_tabs.size()))
{
m_activeIndex = m_tabs.empty() ? -1 : static_cast<int>(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<int>(index) == m_activeIndex);
const bool hovered = (static_cast<int>(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<float>(x), static_cast<float>(y));
}
int TabBar::TabAtPoint(int x, int y) const
{
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
for (std::size_t index = 0; index < m_tabs.size(); ++index)
{
if (UiPointInRect(m_tabs[index].rect, fx, fy))
{
return static_cast<int>(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<std::size_t>(index)].closeRect,
static_cast<float>(x), static_cast<float>(y)));
const bool overAdd = UiPointInRect(m_addRect, static_cast<float>(x), static_cast<float>(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<float>(x), static_cast<float>(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<std::size_t>(index)].closeRect,
static_cast<float>(x), static_cast<float>(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<float>(x), static_cast<float>(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
+3 -3
View File
@@ -21,13 +21,13 @@ namespace jdi
{ {
NONE = 0, /**< 未触发任何动作。 */ NONE = 0, /**< 未触发任何动作。 */
OPEN_FILE, /**< 打开 CSV 文件。 */ OPEN_FILE, /**< 打开 CSV 文件。 */
FIT_ALL, /**< 时间与幅值同时自适应。 */ FIT_ALL, /**< 视图自适应到可见信号范围。 */
FIT_TIME, /**< 仅时间轴自适应。 */
ZOOM_IN, /**< 放大时间轴。 */ ZOOM_IN, /**< 放大时间轴。 */
ZOOM_OUT, /**< 缩小时间轴。 */ ZOOM_OUT, /**< 缩小时间轴。 */
MERGE_LANES, /**< 合并为单一网格。 */ MERGE_LANES, /**< 合并为单一网格。 */
SPLIT_LANES, /**< 按信号拆分为多网格。 */ SPLIT_LANES, /**< 按信号拆分为多网格。 */
RESET_VIEW /**< 恢复默认视图范围。 */ SELECT_ALL, /**< 勾选当前标签页全部信号。 */
CLEAR_ALL /**< 取消当前标签页全部信号勾选。 */
}; };
/** /**
+6 -5
View File
@@ -71,18 +71,20 @@ namespace jdi
const Spec specs[] = { const Spec specs[] = {
{ToolbarAction::OPEN_FILE, L"打开 CSV", true, false}, {ToolbarAction::OPEN_FILE, L"打开 CSV", true, false},
{ToolbarAction::FIT_ALL, L"适应窗口", false, true}, {ToolbarAction::FIT_ALL, L"适应视图", false, true},
{ToolbarAction::FIT_TIME, L"适应时间", false, true},
{ToolbarAction::ZOOM_IN, L"放大", false, true}, {ToolbarAction::ZOOM_IN, L"放大", false, true},
{ToolbarAction::ZOOM_OUT, L"缩小", false, true}, {ToolbarAction::ZOOM_OUT, L"缩小", false, true},
{ToolbarAction::MERGE_LANES, L"合并网格", false, true}, {ToolbarAction::MERGE_LANES, L"合并网格", false, true},
{ToolbarAction::SPLIT_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; float cursorX = area.X + kSidePadding * m_scale;
const float top = area.Y + kTopPadding * m_scale; const float top = area.Y + kTopPadding * m_scale;
const float height = kButtonHeight * m_scale; const float height = kButtonHeight * m_scale;
const float horizontalPadding = kButtonPadding * 2.0f * m_scale;
for (const Spec& spec : specs) for (const Spec& spec : specs)
{ {
@@ -91,8 +93,7 @@ namespace jdi
button.label = spec.label; button.label = spec.label;
button.primary = spec.primary; button.primary = spec.primary;
button.needsDocument = spec.needsDocument; button.needsDocument = spec.needsDocument;
button.width = (kButtonPadding * 2.0f + button.width = horizontalPadding + UiMeasureTextWidth(m_fonts->ui, button.label);
static_cast<float>(button.label.size()) * 13.0f) * m_scale;
button.rect = gd::RectF(cursorX, top, button.width, height); button.rect = gd::RectF(cursorX, top, button.width, height);
cursorX += button.width + kButtonGap * m_scale; cursorX += button.width + kButtonGap * m_scale;
+10 -3
View File
@@ -79,8 +79,10 @@ UNICODE \
# C++ 头文件目录 # C++ 头文件目录
CXX_INCLUDES_PATHS = \ CXX_INCLUDES_PATHS = \
Utilities/Inc \ Utilities/Inc \
Resources/Inc \
Components/Main/Inc \ Components/Main/Inc \
Components/Application/Inc \ Components/Application/Inc \
Components/TabBar/Inc \
Components/Toolbar/Inc \ Components/Toolbar/Inc \
Components/SignalPanel/Inc \ Components/SignalPanel/Inc \
Components/PlotView/Inc \ Components/PlotView/Inc \
@@ -90,6 +92,7 @@ CXX_SOURCES_PATHS = \
Utilities/Src \ Utilities/Src \
Components/Main/Src \ Components/Main/Src \
Components/Application/Src \ Components/Application/Src \
Components/TabBar/Src \
Components/Toolbar/Src \ Components/Toolbar/Src \
Components/SignalPanel/Src \ Components/SignalPanel/Src \
Components/PlotView/Src \ Components/PlotView/Src \
@@ -99,9 +102,13 @@ CXX_EXTERA_SOURCES = \
# Windows 资源文件脚本头文件路径 # Windows 资源文件脚本头文件路径
WIN_RESOURCE_INCLUDES_PATHS = \ WIN_RESOURCE_INCLUDES_PATHS = \
Resources \
Resources/Inc \
# Windows 资源文件脚本输入代码页(对应 INPUT_CHAR_ENCODING = UTF-8)
WIN_RESOURCE_CODEPAGE = 65001
# Windows 资源文件脚本列表 # Windows 资源文件脚本列表
WIN_RESOURCE_SCRIPTS = \ WIN_RESOURCE_SCRIPTS = \
Resources/JustDataInspector.rc \
################################################################################## ##################################################################################
@@ -216,7 +223,7 @@ $(BUILD_DIR)/$(SUB_DIR_RELEASE)/%.o: %.cpp Makefile | $(BUILD_DIR)/$(SUB_DIR_REL
# Release 资源脚本文件编译 # Release 资源脚本文件编译
$(BUILD_DIR)/$(SUB_DIR_RELEASE)/%.o: %.rc Makefile | $(BUILD_DIR)/$(SUB_DIR_RELEASE) $(BUILD_DIR)/$(SUB_DIR_RELEASE)/%.o: %.rc Makefile | $(BUILD_DIR)/$(SUB_DIR_RELEASE)
@echo ====== [Release] Windows Resource Script File "$<" Compiling... ====== @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 目标文件编译 # C Debug 目标文件编译
$(BUILD_DIR)/$(SUB_DIR_DEBUG)/%.o: %.c Makefile | $(BUILD_DIR)/$(SUB_DIR_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 资源脚本文件编译 # Debug 资源脚本文件编译
$(BUILD_DIR)/$(SUB_DIR_DEBUG)/%.o: %.rc Makefile | $(BUILD_DIR)/$(SUB_DIR_DEBUG) $(BUILD_DIR)/$(SUB_DIR_DEBUG)/%.o: %.rc Makefile | $(BUILD_DIR)/$(SUB_DIR_DEBUG)
@echo ====== [Debug] Windows Resource Script File "$<" Compiling... ====== @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 $@
# 依赖关系 # 依赖关系
+15
View File
@@ -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__
Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

+45
View File
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<assemblyIdentity
type="win32"
name="JustDataInspector.Application"
version="1.0.0.0"
processorArchitecture="*"/>
<description>Just DataInspector - CSV 仿真数据波形查看器</description>
<!-- 启用视觉样式(Common Controls v6),使窗口框架与系统控件保持 WinUI 风格。 -->
<dependency>
<dependentAssembly>
<assemblyIdentity
type="win32"
name="Microsoft.Windows.Common-Controls"
version="6.0.0.0"
processorArchitecture="*"
publicKeyToken="6595b64144ccf1df"
language="*"/>
</dependentAssembly>
</dependency>
<!-- 声明 Per-Monitor V2 DPI 感知,配合代码中的 WM_DPICHANGED 处理实现多屏缩放自适应。 -->
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor,system</dpiAwareness>
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
</windowsSettings>
</application>
<!-- 兼容性声明:声明支持的系统版本范围。 -->
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 10 / 11 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
<!-- Windows 8.1 -->
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
<!-- Windows 8 -->
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/>
<!-- Windows 7 -->
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/>
</application>
</compatibility>
</assembly>
+48
View File
@@ -0,0 +1,48 @@
#include <winresrc.h>
#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
+494
View File
@@ -0,0 +1,494 @@
#ifndef __DATA_MODEL_HPP__
#define __DATA_MODEL_HPP__
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
#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<double> 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<double> time; /**< 时间轴数值。 */
std::vector<SignalData> 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<DataFile>& 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<DataFile> 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<SignalRef> 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<Lane>& Lanes() const { return m_lanes; }
/**
* @brief 获取网格集合(可写)。
* @return 网格集合。
*/
std::vector<Lane>& 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<std::vector<SignalStyle>> m_styles; /**< 显示配置,按 [文件][信号] 索引。 */
std::vector<bool> m_collapsed; /**< 文件分组折叠状态,按文件索引。 */
std::vector<Lane> 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__
+8
View File
@@ -148,6 +148,14 @@ namespace jdi
*/ */
float UiTextWidth(gd::Graphics& g, const std::wstring& text, HFONT font); 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 在超出可用宽度时对文本做尾部省略截断。 * @brief 在超出可用宽度时对文本做尾部省略截断。
* @param [IN,OUT] g 绘图上下文。 * @param [IN,OUT] g 绘图上下文。
-212
View File
@@ -1,212 +0,0 @@
#ifndef __SIGNAL_SET_HPP__
#define __SIGNAL_SET_HPP__
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
#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<double> 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<int> 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<Signal>& Signals() { return m_signals; }
/// @brief 信号集合(只读)。
const std::vector<Signal>& Signals() const { return m_signals; }
/// @brief 网格集合(可读写)。
std::vector<Lane>& Lanes() { return m_lanes; }
/// @brief 网格集合(只读)。
const std::vector<Lane>& Lanes() const { return m_lanes; }
/// @brief 时间轴数值(只读)。
const std::vector<double>& 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<double> m_time; /**< 时间轴数值。 */
std::vector<Signal> m_signals; /**< 信号集合。 */
std::vector<Lane> 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__
File diff suppressed because it is too large. Load diff
+22
View File
@@ -236,6 +236,28 @@ namespace jdi
return static_cast<float>(size.cx); return static_cast<float>(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<int>(text.size()), &size);
SelectObject(hdc, oldFont);
ReleaseDC(nullptr, hdc);
return static_cast<float>(size.cx);
}
std::wstring UiEllipsize(gd::Graphics& g, const std::wstring& text, HFONT font, float maxWidth) std::wstring UiEllipsize(gd::Graphics& g, const std::wstring& text, HFONT font, float maxWidth)
{ {
if (text.empty() || font == nullptr || maxWidth <= 0.0f) if (text.empty() || font == nullptr || maxWidth <= 0.0f)
-612
View File
@@ -1,612 +0,0 @@
#include "SignalSet.hpp"
#include "Theme.hpp"
#include "Utf8Util.hpp"
#include <algorithm>
#include <cmath>
#include <cwctype>
#include <limits>
namespace jdi
{
namespace
{
const double kMissing = std::numeric_limits<double>::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<int>(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<std::size_t>(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<std::size_t>(timeColumn)] + L")有效数值不足。";
return false;
}
m_timeColumnName = table.headers[static_cast<std::size_t>(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<int>(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<float>(kDefaultLineWidth);
signal.mode = DrawMode::LINE;
signal.visible = true;
signal.values.resize(rowCount);
double minValue = std::numeric_limits<double>::infinity();
double maxValue = -std::numeric_limits<double>::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<double>::infinity();
double tMax = -std::numeric_limits<double>::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<int>(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<int>(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<int>& indices = m_lanes[lane].signalIndices;
if (std::find(indices.begin(), indices.end(), signalIndex) != indices.end())
{
return static_cast<int>(lane);
}
}
return -1;
}
int SignalSet::MoveSignalToLane(int signalIndex, int laneIndex)
{
if (signalIndex < 0 || signalIndex >= static_cast<int>(m_signals.size()))
{
return -1;
}
for (Lane& lane : m_lanes)
{
std::vector<int>& indices = lane.signalIndices;
indices.erase(std::remove(indices.begin(), indices.end(), signalIndex), indices.end());
}
if (laneIndex < 0 || laneIndex >= static_cast<int>(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<std::size_t>(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<int>(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<int>(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<int>(index) == signalIndex);
}
}
void SignalSet::ComputeLaneRange(int laneIndex, double& yMin, double& yMax) const
{
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()))
{
yMin = 0.0;
yMax = 1.0;
return;
}
const Lane& lane = m_lanes[static_cast<std::size_t>(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<double>::infinity();
double high = -std::numeric_limits<double>::infinity();
for (const int signalIndex : lane.signalIndices)
{
if (signalIndex < 0 || signalIndex >= static_cast<int>(m_signals.size()))
{
continue;
}
const Signal& signal = m_signals[static_cast<std::size_t>(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<double>::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<int>(count - 1);
}
const std::size_t rightIndex = static_cast<std::size_t>(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<int>(leftIndex) : static_cast<int>(rightIndex);
}
int best = 0;
double bestDistance = std::numeric_limits<double>::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<int>(index);
}
}
return best;
}
bool SignalSet::SampleAt(int signalIndex, double time, bool interpolate, double& value) const
{
value = kMissing;
if (signalIndex < 0 || signalIndex >= static_cast<int>(m_signals.size()))
{
return false;
}
if (m_time.empty() || time < m_minTime || time > m_maxTime)
{
return false;
}
const std::vector<double>& samples = m_signals[static_cast<std::size_t>(signalIndex)].values;
if (!m_timeMonotonic)
{
const int nearest = FindNearestSample(time);
if (nearest < 0)
{
return false;
}
value = samples[static_cast<std::size_t>(nearest)];
return std::isfinite(value);
}
const std::vector<double>::const_iterator lower =
std::lower_bound(m_time.begin(), m_time.end(), time);
const std::size_t rightIndex = static_cast<std::size_t>(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<Lane> 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<std::size_t>(paletteCount)];
}
} // namespace jdi