feat: 搭建 Just DataInspector 波形数据查看器基础功能
基于 Win32 API + GDI+ 从零实现仿真数据检查器,用于替代 Simulink.sdi.view 查看带时间轴的 CSV 数据。 - 新增 Utilities 层:UTF-8/宽字符与数值格式化工具、CSV 解析器(自动识别分隔符与引号)、 信号数据集模型(时间轴识别、信号名解析、取值采样)、Solarised Light 主题色板与绘图基础工具 - 新增 Toolbar 组件:打开 CSV、适应窗口、适应时间、放大、缩小、合并网格、每信号一格 - 新增 SignalPanel 组件:信号列表(可见性开关、颜色标识、所属网格标记)、样式编辑区 (颜色、线宽、线段/散点/点线/阶梯显示方式、所属网格),支持右键上下文菜单与滚轮滚动 - 新增 PlotView 组件:多网格波形绘制、自适应时间轴与幅值轴、网格线、信号图例、 时间光标与实时数值读数、纵向滚动条 - 新增 Application 组件:DPI 感知(Per-Monitor V2)、双缓冲绘制、区域布局与分隔条拖拽、 鼠标与键盘消息分发、文件拖放、状态栏提示 - 交互能力:滚轮缩放、Shift+滚轮平移、Ctrl+滚轮缩放幅值、拖拽平移、幅值轴纵向缩放、 右键框选缩放、单击固定/取消时间光标、双击自适应、方向键平移与滚动 - 补齐 Makefile 的源文件与头文件索引,并新增 gdiplus、comdlg32 链接库
This commit is contained in:
commit
132b305544
22 files changed
+7146
No files matched your search
@@ -0,0 +1,43 @@
|
||||
#ifndef __CSV_TABLE_HPP__
|
||||
#define __CSV_TABLE_HPP__
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace jdi
|
||||
{
|
||||
|
||||
/**
|
||||
* @brief CSV 文件的解析结果,保存列名与数值矩阵。
|
||||
*/
|
||||
struct CsvTable
|
||||
{
|
||||
std::vector<std::wstring> headers; /**< 列名(已去除引号与首尾空白)。 */
|
||||
std::vector<std::vector<double>> rows; /**< 数值矩阵,行优先,每行长度与 headers 一致。 */
|
||||
wchar_t delimiter = L','; /**< 实际使用的分隔符。 */
|
||||
std::size_t sourceRowCount = 0; /**< 文件中有效数据行数(不含空行与注释行)。 */
|
||||
std::size_t skippedRowCount = 0; /**< 因格式异常被置为缺失值的行数。 */
|
||||
|
||||
/**
|
||||
* @brief 判断解析结果是否包含可用的表格数据。
|
||||
* @return 存在列名且存在数据行时返回 true。
|
||||
*/
|
||||
bool HasData() const
|
||||
{
|
||||
return !headers.empty() && !rows.empty();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief 读取并解析一个 CSV 文件。
|
||||
* @param [IN] filePath CSV 文件完整路径(宽字符,支持中文路径)。
|
||||
* @param [OUT] table 解析结果输出。
|
||||
* @param [OUT] error 失败原因描述(成功时清空)。
|
||||
* @return 解析成功返回 true,否则返回 false。
|
||||
*/
|
||||
bool LoadCsvTable(const std::wstring& filePath, CsvTable& table, std::wstring& error);
|
||||
|
||||
} // namespace jdi
|
||||
|
||||
#endif // __CSV_TABLE_HPP__
|
||||
@@ -0,0 +1,163 @@
|
||||
#ifndef __PAINT_UTIL_HPP__
|
||||
#define __PAINT_UTIL_HPP__
|
||||
|
||||
#include <windows.h>
|
||||
#include <objidl.h>
|
||||
#include <gdiplus.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
#include "Theme.hpp"
|
||||
|
||||
namespace jdi
|
||||
{
|
||||
|
||||
/**
|
||||
* @brief 文本水平对齐方式(垂直方向始终居中)。
|
||||
*/
|
||||
enum class TextAlign : int
|
||||
{
|
||||
LEFT = 0, /**< 左对齐。 */
|
||||
CENTER = 1, /**< 水平居中。 */
|
||||
RIGHT = 2 /**< 右对齐。 */
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief 按 DPI 创建并缓存整套界面字体(GDI 字体句柄)。
|
||||
*/
|
||||
struct UiFonts
|
||||
{
|
||||
HFONT ui = nullptr; /**< 常规界面字体。 */
|
||||
HFONT uiBold = nullptr; /**< 加粗界面字体。 */
|
||||
HFONT uiSmall = nullptr; /**< 小号界面字体。 */
|
||||
HFONT uiSmallBold = nullptr;/**< 小号加粗界面字体。 */
|
||||
HFONT title = nullptr; /**< 标题字体。 */
|
||||
HFONT mono = nullptr; /**< 等宽数值字体。 */
|
||||
HFONT monoSmall = nullptr; /**< 小号等宽字体。 */
|
||||
UINT dpi = 96; /**< 创建时所使用的 DPI。 */
|
||||
|
||||
/**
|
||||
* @brief 依据 DPI 创建全部字体。
|
||||
* @param [IN] dpi 目标 DPI(96 对应 100% 缩放)。
|
||||
* @return 创建成功返回 true。
|
||||
*/
|
||||
bool Create(UINT dpi);
|
||||
|
||||
/**
|
||||
* @brief 释放全部字体句柄。
|
||||
*/
|
||||
void Destroy();
|
||||
|
||||
~UiFonts() { Destroy(); }
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief 将 0xAARRGGBB 颜色值转换为 GDI COLORREF。
|
||||
* @param [IN] argb 颜色值。
|
||||
* @return GDI 颜色值。
|
||||
*/
|
||||
inline COLORREF ToColorRef(std::uint32_t argb)
|
||||
{
|
||||
return RGB((argb >> 16) & 0xFFu, (argb >> 8) & 0xFFu, argb & 0xFFu);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 判断点是否落在矩形内部。
|
||||
* @param [IN] rect 目标矩形。
|
||||
* @param [IN] x 横坐标。
|
||||
* @param [IN] y 纵坐标。
|
||||
* @return 落在内部返回 true。
|
||||
*/
|
||||
inline bool UiPointInRect(const gd::RectF& rect, float x, float y)
|
||||
{
|
||||
return x >= rect.X && x <= (rect.X + rect.Width) && y >= rect.Y && y <= (rect.Y + rect.Height);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 填充矩形区域。
|
||||
* @param [IN,OUT] g 绘图上下文。
|
||||
* @param [IN] rect 目标矩形。
|
||||
* @param [IN] argb 填充颜色。
|
||||
*/
|
||||
void UiFillRect(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb);
|
||||
|
||||
/**
|
||||
* @brief 填充圆角矩形区域。
|
||||
* @param [IN,OUT] g 绘图上下文。
|
||||
* @param [IN] rect 目标矩形。
|
||||
* @param [IN] radius 圆角半径。
|
||||
* @param [IN] argb 填充颜色。
|
||||
*/
|
||||
void UiFillRoundRect(gd::Graphics& g, const gd::RectF& rect, float radius, std::uint32_t argb);
|
||||
|
||||
/**
|
||||
* @brief 描边圆角矩形。
|
||||
* @param [IN,OUT] g 绘图上下文。
|
||||
* @param [IN] rect 目标矩形。
|
||||
* @param [IN] radius 圆角半径。
|
||||
* @param [IN] argb 线条颜色。
|
||||
* @param [IN] width 线条宽度。
|
||||
*/
|
||||
void UiDrawRoundRect(gd::Graphics& g, const gd::RectF& rect, float radius, std::uint32_t argb, float width);
|
||||
|
||||
/**
|
||||
* @brief 绘制一条直线。
|
||||
* @param [IN,OUT] g 绘图上下文。
|
||||
* @param [IN] x1 起点横坐标。
|
||||
* @param [IN] y1 起点纵坐标。
|
||||
* @param [IN] x2 终点横坐标。
|
||||
* @param [IN] y2 终点纵坐标。
|
||||
* @param [IN] argb 线条颜色。
|
||||
* @param [IN] width 线条宽度。
|
||||
*/
|
||||
void UiDrawLine(gd::Graphics& g, float x1, float y1, float x2, float y2, std::uint32_t argb, float width);
|
||||
|
||||
/**
|
||||
* @brief 绘制一条虚线。
|
||||
* @param [IN,OUT] g 绘图上下文。
|
||||
* @param [IN] x1 起点横坐标。
|
||||
* @param [IN] y1 起点纵坐标。
|
||||
* @param [IN] x2 终点横坐标。
|
||||
* @param [IN] y2 终点纵坐标。
|
||||
* @param [IN] argb 线条颜色。
|
||||
* @param [IN] width 线条宽度。
|
||||
* @param [IN] dashPattern 虚线样式。
|
||||
*/
|
||||
void UiDrawDashedLine(gd::Graphics& g, float x1, float y1, float x2, float y2, std::uint32_t argb,
|
||||
float width, gd::DashStyle dashPattern = gd::DashStyleDash);
|
||||
|
||||
/**
|
||||
* @brief 在指定矩形内绘制单行文本(垂直居中,超长自动省略)。
|
||||
* @param [IN,OUT] g 绘图上下文。
|
||||
* @param [IN] text 文本内容。
|
||||
* @param [IN] font 字体句柄。
|
||||
* @param [IN] rect 文本布局矩形。
|
||||
* @param [IN] argb 文本颜色。
|
||||
* @param [IN] align 水平对齐方式。
|
||||
*/
|
||||
void UiText(gd::Graphics& g, const std::wstring& text, HFONT font, const gd::RectF& rect, std::uint32_t argb,
|
||||
TextAlign align = TextAlign::LEFT);
|
||||
|
||||
/**
|
||||
* @brief 测量单行文本的像素宽度。
|
||||
* @param [IN,OUT] g 绘图上下文。
|
||||
* @param [IN] text 文本内容。
|
||||
* @param [IN] font 字体句柄。
|
||||
* @return 文本像素宽度。
|
||||
*/
|
||||
float UiTextWidth(gd::Graphics& g, const std::wstring& text, HFONT font);
|
||||
|
||||
/**
|
||||
* @brief 在超出可用宽度时对文本做尾部省略截断。
|
||||
* @param [IN,OUT] g 绘图上下文。
|
||||
* @param [IN] text 原始文本。
|
||||
* @param [IN] font 字体句柄。
|
||||
* @param [IN] maxWidth 允许的最大像素宽度。
|
||||
* @return 截断后的文本。
|
||||
*/
|
||||
std::wstring UiEllipsize(gd::Graphics& g, const std::wstring& text, HFONT font, float maxWidth);
|
||||
|
||||
} // namespace jdi
|
||||
|
||||
#endif // __PAINT_UTIL_HPP__
|
||||
@@ -0,0 +1,212 @@
|
||||
#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__
|
||||
@@ -0,0 +1,132 @@
|
||||
#ifndef __THEME_HPP__
|
||||
#define __THEME_HPP__
|
||||
|
||||
#include <windows.h>
|
||||
#include <objidl.h>
|
||||
#include <gdiplus.h>
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
/// @brief GDI+ 命名空间短别名,便于在界面代码中引用绘图类型。
|
||||
namespace gd = Gdiplus;
|
||||
|
||||
namespace jdi
|
||||
{
|
||||
|
||||
/**
|
||||
* @brief 将 0xAARRGGBB 颜色值转换为 GDI+ 颜色对象。
|
||||
* @param [IN] argb 颜色值。
|
||||
* @return GDI+ 颜色对象。
|
||||
*/
|
||||
inline gd::Color ToGdiColor(std::uint32_t argb)
|
||||
{
|
||||
return gd::Color(static_cast<BYTE>((argb >> 24) & 0xFFu), static_cast<BYTE>((argb >> 16) & 0xFFu),
|
||||
static_cast<BYTE>((argb >> 8) & 0xFFu), static_cast<BYTE>(argb & 0xFFu));
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 由 0xRRGGBB 构造不透明的 0xAARRGGBB 颜色值。
|
||||
* @param [IN] rgb RGB 颜色值。
|
||||
* @return 不透明颜色值。
|
||||
*/
|
||||
inline std::uint32_t MakeArgb(std::uint32_t rgb)
|
||||
{
|
||||
return 0xFF000000u | (rgb & 0x00FFFFFFu);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 替换颜色值的 Alpha 通道。
|
||||
* @param [IN] argb 原颜色值。
|
||||
* @param [IN] alpha 新的 Alpha 值(0-255)。
|
||||
* @return 新的颜色值。
|
||||
*/
|
||||
inline std::uint32_t WithAlpha(std::uint32_t argb, unsigned int alpha)
|
||||
{
|
||||
return (argb & 0x00FFFFFFu) | ((static_cast<std::uint32_t>(alpha) & 0xFFu) << 24);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 在两个颜色之间做线性插值。
|
||||
* @param [IN] fromColor 起始颜色。
|
||||
* @param [IN] toColor 结束颜色。
|
||||
* @param [IN] ratio 插值比例,0 返回起始色,1 返回结束色。
|
||||
* @return 插值后的颜色值。
|
||||
*/
|
||||
inline std::uint32_t MixColor(std::uint32_t fromColor, std::uint32_t toColor, double ratio)
|
||||
{
|
||||
if (ratio < 0.0)
|
||||
{
|
||||
ratio = 0.0;
|
||||
}
|
||||
if (ratio > 1.0)
|
||||
{
|
||||
ratio = 1.0;
|
||||
}
|
||||
|
||||
std::uint32_t result = 0;
|
||||
for (int shift = 0; shift <= 24; shift += 8)
|
||||
{
|
||||
const double a = static_cast<double>((fromColor >> shift) & 0xFFu);
|
||||
const double b = static_cast<double>((toColor >> shift) & 0xFFu);
|
||||
const std::uint32_t mixed = static_cast<std::uint32_t>(a + (b - a) * ratio + 0.5);
|
||||
result |= ((mixed & 0xFFu) << shift);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 界面主题色板(Solarised Light 派生)。
|
||||
*/
|
||||
struct Theme
|
||||
{
|
||||
std::uint32_t windowBg = 0; /**< 窗口底色。 */
|
||||
std::uint32_t toolbarBg = 0; /**< 顶部工具栏底色。 */
|
||||
std::uint32_t panelBg = 0; /**< 侧边面板底色。 */
|
||||
std::uint32_t cardBg = 0; /**< 面板内卡片底色。 */
|
||||
std::uint32_t statusBg = 0; /**< 底部状态栏底色。 */
|
||||
std::uint32_t plotBg = 0; /**< 绘图区底色。 */
|
||||
std::uint32_t border = 0; /**< 常规描边色。 */
|
||||
std::uint32_t borderStrong = 0; /**< 强调描边色。 */
|
||||
|
||||
std::uint32_t text = 0; /**< 正文文字色。 */
|
||||
std::uint32_t textStrong = 0; /**< 强调文字色。 */
|
||||
std::uint32_t textMuted = 0; /**< 次要文字色。 */
|
||||
std::uint32_t textOnAccent = 0; /**< 强调色之上的文字色。 */
|
||||
|
||||
std::uint32_t accent = 0; /**< 主题强调色。 */
|
||||
std::uint32_t accentSoft = 0; /**< 强调色的浅色版本。 */
|
||||
std::uint32_t hover = 0; /**< 悬停底色。 */
|
||||
std::uint32_t pressed = 0; /**< 按下底色。 */
|
||||
std::uint32_t selection = 0; /**< 选中底色。 */
|
||||
|
||||
std::uint32_t gridWeak = 0; /**< 次级网格线颜色。 */
|
||||
std::uint32_t gridStrong = 0; /**< 主要网格线颜色。 */
|
||||
std::uint32_t axisLine = 0; /**< 坐标轴线颜色。 */
|
||||
std::uint32_t cursor = 0; /**< 时间光标颜色。 */
|
||||
std::uint32_t readoutBg = 0; /**< 数值读数底色。 */
|
||||
std::uint32_t danger = 0; /**< 警示色。 */
|
||||
std::uint32_t warning = 0; /**< 提示色。 */
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief 获取当前使用的主题(Solarised Light)。
|
||||
* @return 主题常引用。
|
||||
*/
|
||||
const Theme& SolarizedLight();
|
||||
|
||||
/**
|
||||
* @brief 获取波形默认配色表。
|
||||
* @param [OUT] count 配色数量输出。
|
||||
* @return 颜色数组首地址。
|
||||
*/
|
||||
const std::uint32_t* SignalPalette(int& count);
|
||||
|
||||
/**
|
||||
* @brief 获取波形默认配色数量。
|
||||
* @return 配色数量。
|
||||
*/
|
||||
int SignalColorCount();
|
||||
|
||||
} // namespace jdi
|
||||
|
||||
#endif // __THEME_HPP__
|
||||
@@ -0,0 +1,121 @@
|
||||
#ifndef __UTF8_UTIL_HPP__
|
||||
#define __UTF8_UTIL_HPP__
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace jdi
|
||||
{
|
||||
|
||||
/**
|
||||
* @brief 将 UTF-8 编码的字节序列转换为宽字符串(UTF-16LE)。
|
||||
* @param [IN] text UTF-8 文本起始地址。
|
||||
* @param [IN] length 文本字节数,传入负值表示按 '\0' 结尾自动计算长度。
|
||||
* @return 转换后的宽字符串,输入非法时返回空字符串。
|
||||
*/
|
||||
std::wstring Utf8ToWide(const char* text, int length = -1);
|
||||
|
||||
/**
|
||||
* @brief 将宽字符串(UTF-16LE)转换为 UTF-8 编码的字节序列。
|
||||
* @param [IN] text 宽字符文本起始地址。
|
||||
* @return 转换后的 UTF-8 字符串,输入非法时返回空字符串。
|
||||
*/
|
||||
std::string WideToUtf8(const wchar_t* text);
|
||||
|
||||
/**
|
||||
* @brief 去除字符串首尾的空白字符(含制表、换行与不间断空格)。
|
||||
* @param [IN] text 待处理文本。
|
||||
* @return 去除首尾空白后的新字符串。
|
||||
*/
|
||||
std::wstring Trim(const std::wstring& text);
|
||||
|
||||
/**
|
||||
* @brief 去除字符串首尾的成对引号(单引号或双引号)。
|
||||
* @param [IN] text 待处理文本。
|
||||
* @return 去除引号后的新字符串。
|
||||
*/
|
||||
std::wstring StripQuotes(const std::wstring& text);
|
||||
|
||||
/**
|
||||
* @brief 仅针对 ASCII 字母执行小写转换(其余字符原样保留)。
|
||||
* @param [IN] text 待处理文本。
|
||||
* @return 转换后的新字符串。
|
||||
*/
|
||||
std::wstring ToLowerAscii(const std::wstring& text);
|
||||
|
||||
/**
|
||||
* @brief 判断字符串是否以指定前缀开头。
|
||||
* @param [IN] text 被检索文本。
|
||||
* @param [IN] prefix 前缀文本。
|
||||
* @return 以该前缀开头返回 true,否则返回 false。
|
||||
*/
|
||||
bool StartsWith(const std::wstring& text, const std::wstring& prefix);
|
||||
|
||||
/**
|
||||
* @brief 判断字符串是否包含指定子串(ASCII 大小写不敏感)。
|
||||
* @param [IN] text 被检索文本。
|
||||
* @param [IN] needle 子串文本。
|
||||
* @return 包含返回 true,否则返回 false。
|
||||
*/
|
||||
bool ContainsInsensitive(const std::wstring& text, const std::wstring& needle);
|
||||
|
||||
/**
|
||||
* @brief 从字符区间中解析一个双精度浮点数(允许尾部存在非数字残留字符)。
|
||||
* @param [IN] begin 区间起始指针。
|
||||
* @param [IN] end 区间结束指针(不包含)。
|
||||
* @param [OUT] value 解析结果输出。
|
||||
* @return 解析成功返回 true,区间为空或完全无法识别时返回 false。
|
||||
*/
|
||||
bool TryParseDouble(const char* begin, const char* end, double& value);
|
||||
|
||||
/**
|
||||
* @brief 以固定小数位格式化数值。
|
||||
* @param [IN] value 待格式化数值。
|
||||
* @param [IN] decimals 小数位数。
|
||||
* @return 格式化后的文本。
|
||||
*/
|
||||
std::wstring FormatNumber(double value, int decimals);
|
||||
|
||||
/**
|
||||
* @brief 以自适应有效位格式化数值(NaN/Inf 会被转换为可读文本)。
|
||||
* @param [IN] value 待格式化数值。
|
||||
* @return 格式化后的文本。
|
||||
*/
|
||||
std::wstring FormatValue(double value);
|
||||
|
||||
/**
|
||||
* @brief 依据坐标轴刻度步长格式化刻度标签。
|
||||
* @param [IN] value 刻度值。
|
||||
* @param [IN] step 刻度步长,用于推断所需小数位。
|
||||
* @return 刻度标签文本。
|
||||
*/
|
||||
std::wstring FormatTick(double value, double step);
|
||||
|
||||
/**
|
||||
* @brief 将整数格式化为带千位分隔符的文本。
|
||||
* @param [IN] value 待格式化整数。
|
||||
* @return 格式化后的文本。
|
||||
*/
|
||||
std::wstring FormatCount(unsigned long long value);
|
||||
|
||||
/**
|
||||
* @brief 计算“美观”的坐标轴刻度步长(1/2/5 乘以 10 的整数次幂)。
|
||||
* @param [IN] range 坐标轴数值跨度,必须为正数。
|
||||
* @param [IN] targetTicks 期望的刻度数量。
|
||||
* @return 刻度步长,输入非法时返回 1.0。
|
||||
*/
|
||||
double NiceStep(double range, int targetTicks);
|
||||
|
||||
/**
|
||||
* @brief 依据范围与步长生成刻度值序列。
|
||||
* @param [IN] minValue 范围下限。
|
||||
* @param [IN] maxValue 范围上限。
|
||||
* @param [IN] step 刻度步长。
|
||||
* @param [OUT] ticks 输出刻度序列(按升序排列)。
|
||||
*/
|
||||
void BuildTicks(double minValue, double maxValue, double step, std::vector<double>& ticks);
|
||||
|
||||
} // namespace jdi
|
||||
|
||||
#endif // __UTF8_UTIL_HPP__
|
||||
@@ -0,0 +1,349 @@
|
||||
#include "CsvTable.hpp"
|
||||
|
||||
#include "Utf8Util.hpp"
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <limits>
|
||||
|
||||
namespace jdi
|
||||
{
|
||||
|
||||
namespace
|
||||
{
|
||||
/** @brief 单个字段在原始缓冲中的位置区间。 */
|
||||
struct FieldSpan
|
||||
{
|
||||
const char* begin = nullptr; /**< 字段起始指针。 */
|
||||
const char* end = nullptr; /**< 字段结束指针(不包含)。 */
|
||||
};
|
||||
|
||||
const double kMissingValue = std::numeric_limits<double>::quiet_NaN(); /**< 缺失值占位。 */
|
||||
|
||||
/**
|
||||
* @brief 一次性将整个文件读入内存。
|
||||
* @param [IN] filePath 文件路径。
|
||||
* @param [OUT] buffer 文件内容输出。
|
||||
* @param [OUT] error 失败原因描述。
|
||||
* @return 读取成功返回 true。
|
||||
*/
|
||||
bool ReadWholeFile(const std::wstring& filePath, std::string& buffer, std::wstring& error)
|
||||
{
|
||||
FILE* file = _wfopen(filePath.c_str(), L"rb");
|
||||
if (file == nullptr)
|
||||
{
|
||||
error = L"无法打开文件,请确认路径与访问权限。";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (std::fseek(file, 0, SEEK_END) != 0)
|
||||
{
|
||||
std::fclose(file);
|
||||
error = L"无法定位文件末尾。";
|
||||
return false;
|
||||
}
|
||||
|
||||
const long fileSize = std::ftell(file);
|
||||
if (fileSize < 0)
|
||||
{
|
||||
std::fclose(file);
|
||||
error = L"无法获取文件长度。";
|
||||
return false;
|
||||
}
|
||||
if (fileSize == 0)
|
||||
{
|
||||
std::fclose(file);
|
||||
error = L"文件内容为空。";
|
||||
return false;
|
||||
}
|
||||
if (fileSize > (512L * 1024L * 1024L))
|
||||
{
|
||||
std::fclose(file);
|
||||
error = L"文件体积超过 512 MB,暂不支持。";
|
||||
return false;
|
||||
}
|
||||
|
||||
std::fseek(file, 0, SEEK_SET);
|
||||
buffer.resize(static_cast<size_t>(fileSize));
|
||||
const size_t readCount = std::fread(&buffer[0], 1, buffer.size(), file);
|
||||
std::fclose(file);
|
||||
|
||||
buffer.resize(readCount);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 统计一行中某个候选分隔符出现的次数。
|
||||
* @param [IN] begin 行起始指针。
|
||||
* @param [IN] end 行结束指针(不包含)。
|
||||
* @param [IN] candidate 候选分隔符。
|
||||
* @return 出现次数。
|
||||
*/
|
||||
int CountDelimiter(const char* begin, const char* end, char candidate)
|
||||
{
|
||||
int count = 0;
|
||||
bool inQuote = false;
|
||||
for (const char* p = begin; p < end; ++p)
|
||||
{
|
||||
if (*p == '"')
|
||||
{
|
||||
inQuote = !inQuote;
|
||||
}
|
||||
else if (*p == candidate && !inQuote)
|
||||
{
|
||||
++count;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 依据表头行推断分隔符。
|
||||
* @param [IN] begin 表头行起始指针。
|
||||
* @param [IN] end 表头行结束指针(不包含)。
|
||||
* @return 推断出的分隔符。
|
||||
*/
|
||||
char DetectDelimiter(const char* begin, const char* end)
|
||||
{
|
||||
static const char kCandidates[] = {',', ';', '\t', '|'};
|
||||
|
||||
char best = ',';
|
||||
int bestCount = 0;
|
||||
for (const char candidate : kCandidates)
|
||||
{
|
||||
const int count = CountDelimiter(begin, end, candidate);
|
||||
if (count > bestCount)
|
||||
{
|
||||
bestCount = count;
|
||||
best = candidate;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 将一行文本按分隔符拆分为多个字段(支持双引号包裹与转义)。
|
||||
* @param [IN] begin 行起始指针。
|
||||
* @param [IN] end 行结束指针(不包含)。
|
||||
* @param [IN] delimiter 分隔符。
|
||||
* @param [OUT] fields 字段区间输出。
|
||||
*/
|
||||
void SplitFields(const char* begin, const char* end, char delimiter, std::vector<FieldSpan>& fields)
|
||||
{
|
||||
fields.clear();
|
||||
|
||||
const char* fieldBegin = begin;
|
||||
bool inQuote = false;
|
||||
for (const char* p = begin; p < end; ++p)
|
||||
{
|
||||
const char c = *p;
|
||||
if (c == '"')
|
||||
{
|
||||
if (inQuote && (p + 1) < end && p[1] == '"')
|
||||
{
|
||||
++p;
|
||||
continue;
|
||||
}
|
||||
inQuote = !inQuote;
|
||||
}
|
||||
else if (c == delimiter && !inQuote)
|
||||
{
|
||||
FieldSpan span;
|
||||
span.begin = fieldBegin;
|
||||
span.end = p;
|
||||
fields.push_back(span);
|
||||
fieldBegin = p + 1;
|
||||
}
|
||||
}
|
||||
|
||||
FieldSpan span;
|
||||
span.begin = fieldBegin;
|
||||
span.end = end;
|
||||
fields.push_back(span);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 将字段区间转换为字符串并去除引号与空白。
|
||||
* @param [IN] span 字段区间。
|
||||
* @return 规范化后的字段文本。
|
||||
*/
|
||||
std::wstring FieldToText(const FieldSpan& span)
|
||||
{
|
||||
std::wstring text = Utf8ToWide(span.begin, static_cast<int>(span.end - span.begin));
|
||||
return StripQuotes(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 判断字段是否应当被整行跳过(空行或注释行)。
|
||||
* @param [IN] span 首字段区间。
|
||||
* @param [IN] lineIsEmpty 该行是否为纯空白行。
|
||||
* @return 需要跳过返回 true。
|
||||
*/
|
||||
bool ShouldSkipLine(const FieldSpan& span, bool lineIsEmpty)
|
||||
{
|
||||
if (lineIsEmpty)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (span.begin == nullptr || span.begin >= span.end)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
const char first = span.begin[0];
|
||||
return first == '#' || first == '%';
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 在一行内查找首个非空行的终止位置。
|
||||
* @param [IN] begin 文本起始指针。
|
||||
* @param [IN] end 文本结束指针(不包含)。
|
||||
* @return 该行结束指针(不含换行符)。
|
||||
*/
|
||||
const char* FindLineEnd(const char* begin, const char* end)
|
||||
{
|
||||
const char* p = begin;
|
||||
while (p < end && *p != '\n')
|
||||
{
|
||||
++p;
|
||||
}
|
||||
const char* lineEnd = p;
|
||||
while (lineEnd > begin && (lineEnd[-1] == '\r' || lineEnd[-1] == '\n'))
|
||||
{
|
||||
--lineEnd;
|
||||
}
|
||||
return lineEnd;
|
||||
}
|
||||
}
|
||||
|
||||
bool LoadCsvTable(const std::wstring& filePath, CsvTable& table, std::wstring& error)
|
||||
{
|
||||
table = CsvTable();
|
||||
error.clear();
|
||||
|
||||
std::string buffer;
|
||||
if (!ReadWholeFile(filePath, buffer, error))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const char* data = buffer.data();
|
||||
size_t length = buffer.size();
|
||||
|
||||
// 跳过 UTF-8 BOM,避免首列名被污染。
|
||||
if (length >= 3 && static_cast<unsigned char>(data[0]) == 0xEF &&
|
||||
static_cast<unsigned char>(data[1]) == 0xBB && static_cast<unsigned char>(data[2]) == 0xBF)
|
||||
{
|
||||
data += 3;
|
||||
length -= 3;
|
||||
}
|
||||
|
||||
const char* cursor = data;
|
||||
const char* limit = data + length;
|
||||
|
||||
std::vector<FieldSpan> fields;
|
||||
bool headerReady = false;
|
||||
|
||||
while (cursor < limit)
|
||||
{
|
||||
const char* rawEnd = FindLineEnd(cursor, limit);
|
||||
const char* lineEnd = rawEnd;
|
||||
|
||||
// 跳过纯空白行,但不产生数据行。
|
||||
const char* scan = cursor;
|
||||
while (scan < lineEnd && (*scan == ' ' || *scan == '\t' || *scan == '\r'))
|
||||
{
|
||||
++scan;
|
||||
}
|
||||
const bool lineIsEmpty = (scan >= lineEnd);
|
||||
|
||||
if (!headerReady && lineIsEmpty)
|
||||
{
|
||||
cursor = (rawEnd < limit) ? (rawEnd + 1) : limit;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!headerReady)
|
||||
{
|
||||
table.delimiter = DetectDelimiter(cursor, lineEnd);
|
||||
SplitFields(cursor, lineEnd, static_cast<char>(table.delimiter), fields);
|
||||
|
||||
table.headers.clear();
|
||||
table.headers.reserve(fields.size());
|
||||
for (const FieldSpan& span : fields)
|
||||
{
|
||||
std::wstring text = FieldToText(span);
|
||||
if (text.empty())
|
||||
{
|
||||
text = L"Column" + std::to_wstring(table.headers.size() + 1);
|
||||
}
|
||||
table.headers.push_back(text);
|
||||
}
|
||||
|
||||
if (table.headers.empty())
|
||||
{
|
||||
error = L"表头为空,无法解析列名。";
|
||||
return false;
|
||||
}
|
||||
|
||||
headerReady = true;
|
||||
cursor = (rawEnd < limit) ? (rawEnd + 1) : limit;
|
||||
continue;
|
||||
}
|
||||
|
||||
SplitFields(cursor, lineEnd, static_cast<char>(table.delimiter), fields);
|
||||
|
||||
// 空行与注释行(以 # 或 % 开头)不参与数据统计。
|
||||
if (ShouldSkipLine(fields.empty() ? FieldSpan() : fields.front(), lineIsEmpty))
|
||||
{
|
||||
cursor = (rawEnd < limit) ? (rawEnd + 1) : limit;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (fields.size() != table.headers.size())
|
||||
{
|
||||
++table.skippedRowCount;
|
||||
}
|
||||
|
||||
std::vector<double> row;
|
||||
row.reserve(table.headers.size());
|
||||
bool rowHasValue = false;
|
||||
|
||||
for (size_t column = 0; column < table.headers.size(); ++column)
|
||||
{
|
||||
if (column >= fields.size())
|
||||
{
|
||||
row.push_back(kMissingValue);
|
||||
continue;
|
||||
}
|
||||
double value = kMissingValue;
|
||||
if (TryParseDouble(fields[column].begin, fields[column].end, value))
|
||||
{
|
||||
rowHasValue = true;
|
||||
}
|
||||
row.push_back(value);
|
||||
}
|
||||
|
||||
if (!rowHasValue)
|
||||
{
|
||||
cursor = (rawEnd < limit) ? (rawEnd + 1) : limit;
|
||||
continue;
|
||||
}
|
||||
|
||||
table.rows.push_back(std::move(row));
|
||||
++table.sourceRowCount;
|
||||
|
||||
cursor = (rawEnd < limit) ? (rawEnd + 1) : limit;
|
||||
}
|
||||
|
||||
if (!table.HasData())
|
||||
{
|
||||
error = L"未从文件中解析出有效数据行。";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace jdi
|
||||
@@ -0,0 +1,269 @@
|
||||
#include "PaintUtil.hpp"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace jdi
|
||||
{
|
||||
|
||||
namespace
|
||||
{
|
||||
/**
|
||||
* @brief 构造圆角矩形路径。
|
||||
* @param [IN,OUT] path 目标路径对象。
|
||||
* @param [IN] rect 目标矩形。
|
||||
* @param [IN] radius 圆角半径。
|
||||
*/
|
||||
void BuildRoundRectPath(gd::GraphicsPath& path, const gd::RectF& rect, float radius)
|
||||
{
|
||||
if (rect.Width <= 0.0f || rect.Height <= 0.0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
float diameter = radius * 2.0f;
|
||||
if (diameter > rect.Width)
|
||||
{
|
||||
diameter = rect.Width;
|
||||
}
|
||||
if (diameter > rect.Height)
|
||||
{
|
||||
diameter = rect.Height;
|
||||
}
|
||||
if (diameter < 1.0f)
|
||||
{
|
||||
path.AddRectangle(rect);
|
||||
return;
|
||||
}
|
||||
|
||||
const float right = rect.X + rect.Width;
|
||||
const float bottom = rect.Y + rect.Height;
|
||||
|
||||
path.AddArc(rect.X, rect.Y, diameter, diameter, 180.0f, 90.0f);
|
||||
path.AddArc(right - diameter, rect.Y, diameter, diameter, 270.0f, 90.0f);
|
||||
path.AddArc(right - diameter, bottom - diameter, diameter, diameter, 0.0f, 90.0f);
|
||||
path.AddArc(rect.X, bottom - diameter, diameter, diameter, 90.0f, 90.0f);
|
||||
path.CloseFigure();
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 创建指定字号的界面字体。
|
||||
* @param [IN] face 字体名称。
|
||||
* @param [IN] pixelHeight 字符高度(像素)。
|
||||
* @param [IN] bold 是否加粗。
|
||||
* @return 字体句柄,失败返回 nullptr。
|
||||
*/
|
||||
HFONT CreateUiFont(const wchar_t* face, int pixelHeight, bool bold)
|
||||
{
|
||||
LOGFONTW logFont = {};
|
||||
logFont.lfHeight = -pixelHeight;
|
||||
logFont.lfWeight = bold ? FW_SEMIBOLD : FW_NORMAL;
|
||||
logFont.lfCharSet = DEFAULT_CHARSET;
|
||||
logFont.lfOutPrecision = OUT_TT_PRECIS;
|
||||
logFont.lfClipPrecision = CLIP_DEFAULT_PRECIS;
|
||||
logFont.lfQuality = CLEARTYPE_QUALITY;
|
||||
logFont.lfPitchAndFamily = DEFAULT_PITCH | FF_DONTCARE;
|
||||
wcsncpy(logFont.lfFaceName, face, LF_FACESIZE - 1);
|
||||
logFont.lfFaceName[LF_FACESIZE - 1] = L'\0';
|
||||
|
||||
HFONT font = CreateFontIndirectW(&logFont);
|
||||
if (font == nullptr)
|
||||
{
|
||||
font = static_cast<HFONT>(GetStockObject(DEFAULT_GUI_FONT));
|
||||
}
|
||||
return font;
|
||||
}
|
||||
}
|
||||
|
||||
bool UiFonts::Create(UINT targetDpi)
|
||||
{
|
||||
Destroy();
|
||||
dpi = (targetDpi == 0) ? 96u : targetDpi;
|
||||
|
||||
const double scale = static_cast<double>(dpi) / 96.0;
|
||||
const int uiHeight = static_cast<int>(std::lround(14.0 * scale));
|
||||
const int smallHeight = static_cast<int>(std::lround(12.0 * scale));
|
||||
const int titleHeight = static_cast<int>(std::lround(15.0 * scale));
|
||||
const int monoHeight = static_cast<int>(std::lround(13.0 * scale));
|
||||
|
||||
ui = CreateUiFont(L"Segoe UI", uiHeight, false);
|
||||
uiBold = CreateUiFont(L"Segoe UI Semibold", uiHeight, true);
|
||||
uiSmall = CreateUiFont(L"Segoe UI", smallHeight, false);
|
||||
uiSmallBold = CreateUiFont(L"Segoe UI Semibold", smallHeight, true);
|
||||
title = CreateUiFont(L"Segoe UI Semibold", titleHeight, true);
|
||||
mono = CreateUiFont(L"Consolas", monoHeight, false);
|
||||
monoSmall = CreateUiFont(L"Consolas", smallHeight, false);
|
||||
|
||||
return ui != nullptr && uiBold != nullptr && uiSmall != nullptr && title != nullptr && mono != nullptr;
|
||||
}
|
||||
|
||||
void UiFonts::Destroy()
|
||||
{
|
||||
const HFONT resources[] = {ui, uiBold, uiSmall, uiSmallBold, title, mono, monoSmall};
|
||||
for (const HFONT font : resources)
|
||||
{
|
||||
if (font != nullptr)
|
||||
{
|
||||
DeleteObject(font);
|
||||
}
|
||||
}
|
||||
ui = nullptr;
|
||||
uiBold = nullptr;
|
||||
uiSmall = nullptr;
|
||||
uiSmallBold = nullptr;
|
||||
title = nullptr;
|
||||
mono = nullptr;
|
||||
monoSmall = nullptr;
|
||||
}
|
||||
|
||||
void UiFillRect(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb)
|
||||
{
|
||||
if (rect.Width <= 0.0f || rect.Height <= 0.0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
gd::SolidBrush brush(ToGdiColor(argb));
|
||||
g.FillRectangle(&brush, rect);
|
||||
}
|
||||
|
||||
void UiFillRoundRect(gd::Graphics& g, const gd::RectF& rect, float radius, std::uint32_t argb)
|
||||
{
|
||||
if (rect.Width <= 0.0f || rect.Height <= 0.0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
gd::GraphicsPath path;
|
||||
BuildRoundRectPath(path, rect, radius);
|
||||
gd::SolidBrush brush(ToGdiColor(argb));
|
||||
g.FillPath(&brush, &path);
|
||||
}
|
||||
|
||||
void UiDrawRoundRect(gd::Graphics& g, const gd::RectF& rect, float radius, std::uint32_t argb, float width)
|
||||
{
|
||||
if (rect.Width <= 0.0f || rect.Height <= 0.0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
gd::GraphicsPath path;
|
||||
BuildRoundRectPath(path, rect, radius);
|
||||
gd::Pen pen(ToGdiColor(argb), width);
|
||||
g.DrawPath(&pen, &path);
|
||||
}
|
||||
|
||||
void UiDrawLine(gd::Graphics& g, float x1, float y1, float x2, float y2, std::uint32_t argb, float width)
|
||||
{
|
||||
gd::Pen pen(ToGdiColor(argb), width);
|
||||
pen.SetStartCap(gd::LineCapFlat);
|
||||
pen.SetEndCap(gd::LineCapFlat);
|
||||
g.DrawLine(&pen, x1, y1, x2, y2);
|
||||
}
|
||||
|
||||
void UiDrawDashedLine(gd::Graphics& g, float x1, float y1, float x2, float y2, std::uint32_t argb,
|
||||
float width, gd::DashStyle dashPattern)
|
||||
{
|
||||
gd::Pen pen(ToGdiColor(argb), width);
|
||||
pen.SetDashStyle(dashPattern);
|
||||
pen.SetStartCap(gd::LineCapFlat);
|
||||
pen.SetEndCap(gd::LineCapFlat);
|
||||
g.DrawLine(&pen, x1, y1, x2, y2);
|
||||
}
|
||||
|
||||
void UiText(gd::Graphics& g, const std::wstring& text, HFONT font, const gd::RectF& rect, std::uint32_t argb,
|
||||
TextAlign align)
|
||||
{
|
||||
if (text.empty() || font == nullptr || rect.Width <= 0.0f || rect.Height <= 0.0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
HDC hdc = g.GetHDC();
|
||||
if (hdc == nullptr)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
HGDIOBJ oldFont = SelectObject(hdc, font);
|
||||
const int oldMode = SetBkMode(hdc, TRANSPARENT);
|
||||
const COLORREF oldColor = SetTextColor(hdc, ToColorRef(argb));
|
||||
|
||||
RECT rc;
|
||||
rc.left = static_cast<LONG>(std::floor(rect.X));
|
||||
rc.top = static_cast<LONG>(std::floor(rect.Y));
|
||||
rc.right = static_cast<LONG>(std::ceil(rect.X + rect.Width));
|
||||
rc.bottom = static_cast<LONG>(std::ceil(rect.Y + rect.Height));
|
||||
|
||||
UINT flags = DT_SINGLELINE | DT_VCENTER | DT_NOPREFIX | DT_END_ELLIPSIS;
|
||||
switch (align)
|
||||
{
|
||||
case TextAlign::CENTER:
|
||||
flags |= DT_CENTER;
|
||||
break;
|
||||
case TextAlign::RIGHT:
|
||||
flags |= DT_RIGHT;
|
||||
break;
|
||||
case TextAlign::LEFT:
|
||||
default:
|
||||
flags |= DT_LEFT;
|
||||
break;
|
||||
}
|
||||
|
||||
DrawTextW(hdc, text.c_str(), static_cast<int>(text.size()), &rc, flags);
|
||||
|
||||
SetTextColor(hdc, oldColor);
|
||||
SetBkMode(hdc, oldMode);
|
||||
SelectObject(hdc, oldFont);
|
||||
g.ReleaseHDC(hdc);
|
||||
}
|
||||
|
||||
float UiTextWidth(gd::Graphics& g, const std::wstring& text, HFONT font)
|
||||
{
|
||||
if (text.empty() || font == nullptr)
|
||||
{
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
HDC hdc = g.GetHDC();
|
||||
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);
|
||||
g.ReleaseHDC(hdc);
|
||||
|
||||
return static_cast<float>(size.cx);
|
||||
}
|
||||
|
||||
std::wstring UiEllipsize(gd::Graphics& g, const std::wstring& text, HFONT font, float maxWidth)
|
||||
{
|
||||
if (text.empty() || font == nullptr || maxWidth <= 0.0f)
|
||||
{
|
||||
return std::wstring();
|
||||
}
|
||||
if (UiTextWidth(g, text, font) <= maxWidth)
|
||||
{
|
||||
return text;
|
||||
}
|
||||
|
||||
const std::wstring ellipsis = L"…";
|
||||
const float ellipsisWidth = UiTextWidth(g, ellipsis, font);
|
||||
if (ellipsisWidth > maxWidth)
|
||||
{
|
||||
return std::wstring();
|
||||
}
|
||||
|
||||
std::wstring result = text;
|
||||
while (!result.empty())
|
||||
{
|
||||
result.pop_back();
|
||||
if (UiTextWidth(g, result, font) + ellipsisWidth <= maxWidth)
|
||||
{
|
||||
return result + ellipsis;
|
||||
}
|
||||
}
|
||||
return ellipsis;
|
||||
}
|
||||
|
||||
} // namespace jdi
|
||||
@@ -0,0 +1,612 @@
|
||||
#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
|
||||
@@ -0,0 +1,86 @@
|
||||
#include "Theme.hpp"
|
||||
|
||||
namespace jdi
|
||||
{
|
||||
|
||||
namespace
|
||||
{
|
||||
// Solarised Light 基础色板。
|
||||
const std::uint32_t kBase03 = 0x002B36u; // #002B36
|
||||
const std::uint32_t kBase02 = 0x073642u; // #073642
|
||||
const std::uint32_t kBase01 = 0x586E75u; // #586E75
|
||||
const std::uint32_t kBase00 = 0x657B83u; // #657B83
|
||||
const std::uint32_t kBase0 = 0x839496u; // #839496
|
||||
const std::uint32_t kBase1 = 0x93A1A1u; // #93A1A1
|
||||
const std::uint32_t kBase2 = 0xEEE8D5u; // #EEE8D5
|
||||
const std::uint32_t kBase3 = 0xFDF6E3u; // #FDF6E3
|
||||
|
||||
// Solarised 强调色。
|
||||
const std::uint32_t kYellow = 0xB58900u; // #B58900
|
||||
const std::uint32_t kOrange = 0xCB4B16u; // #CB4B16
|
||||
const std::uint32_t kRed = 0xDC322Fu; // #DC322F
|
||||
const std::uint32_t kMagenta = 0xD33682u; // #D33682
|
||||
const std::uint32_t kViolet = 0x6C71C4u; // #6C71C4
|
||||
const std::uint32_t kBlue = 0x268BD2u; // #268BD2
|
||||
const std::uint32_t kCyan = 0x2AA198u; // #2AA198
|
||||
const std::uint32_t kGreen = 0x859900u; // #859900
|
||||
|
||||
/** @brief 波形默认配色,按视觉对比度排序。 */
|
||||
const std::uint32_t kSignalPalette[] = {
|
||||
MakeArgb(kBlue), MakeArgb(kGreen), MakeArgb(kRed), MakeArgb(kViolet),
|
||||
MakeArgb(kYellow), MakeArgb(kCyan), MakeArgb(kMagenta), MakeArgb(kOrange),
|
||||
MakeArgb(kBase01), MakeArgb(kBase0),
|
||||
};
|
||||
|
||||
/** @brief 全局唯一主题实例。 */
|
||||
const Theme kSolarizedLight = []
|
||||
{
|
||||
Theme theme;
|
||||
theme.windowBg = MakeArgb(kBase3);
|
||||
theme.toolbarBg = MakeArgb(kBase2);
|
||||
theme.panelBg = MakeArgb(kBase2);
|
||||
theme.cardBg = MakeArgb(kBase3);
|
||||
theme.statusBg = MakeArgb(kBase2);
|
||||
theme.plotBg = MakeArgb(kBase3);
|
||||
theme.border = MakeArgb(0xD9D1BCu);
|
||||
theme.borderStrong = MakeArgb(kBase1);
|
||||
|
||||
theme.text = MakeArgb(kBase00);
|
||||
theme.textStrong = MakeArgb(kBase02);
|
||||
theme.textMuted = MakeArgb(kBase1);
|
||||
theme.textOnAccent = MakeArgb(kBase3);
|
||||
|
||||
theme.accent = MakeArgb(kBlue);
|
||||
theme.accentSoft = MakeArgb(0xCFE4F3u);
|
||||
theme.hover = MakeArgb(0xE4DCC6u);
|
||||
theme.pressed = MakeArgb(0xD8CFB6u);
|
||||
theme.selection = MakeArgb(0xDCEAF6u);
|
||||
|
||||
theme.gridWeak = MakeArgb(0xECE5D1u);
|
||||
theme.gridStrong = MakeArgb(0xDCD4BEu);
|
||||
theme.axisLine = MakeArgb(kBase1);
|
||||
theme.cursor = MakeArgb(kOrange);
|
||||
theme.readoutBg = MakeArgb(0xF6EFDCu);
|
||||
theme.danger = MakeArgb(kRed);
|
||||
theme.warning = MakeArgb(kYellow);
|
||||
return theme;
|
||||
}();
|
||||
}
|
||||
|
||||
const Theme& SolarizedLight()
|
||||
{
|
||||
return kSolarizedLight;
|
||||
}
|
||||
|
||||
const std::uint32_t* SignalPalette(int& count)
|
||||
{
|
||||
count = static_cast<int>(sizeof(kSignalPalette) / sizeof(kSignalPalette[0]));
|
||||
return kSignalPalette;
|
||||
}
|
||||
|
||||
int SignalColorCount()
|
||||
{
|
||||
return static_cast<int>(sizeof(kSignalPalette) / sizeof(kSignalPalette[0]));
|
||||
}
|
||||
|
||||
} // namespace jdi
|
||||
@@ -0,0 +1,370 @@
|
||||
#include "Utf8Util.hpp"
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
|
||||
namespace jdi
|
||||
{
|
||||
|
||||
namespace
|
||||
{
|
||||
/** @brief 判断字符是否为需要裁掉的空白字符。 */
|
||||
bool IsSpaceChar(wchar_t c)
|
||||
{
|
||||
return c == L' ' || c == L'\t' || c == L'\r' || c == L'\n' || c == L'\v' || c == L'\f' ||
|
||||
c == static_cast<wchar_t>(0x00A0);
|
||||
}
|
||||
|
||||
/** @brief 判断字符是否为 ASCII 大写字母。 */
|
||||
bool IsUpperAscii(wchar_t c)
|
||||
{
|
||||
return c >= L'A' && c <= L'Z';
|
||||
}
|
||||
}
|
||||
|
||||
std::wstring Utf8ToWide(const char* text, int length)
|
||||
{
|
||||
if (text == nullptr)
|
||||
{
|
||||
return std::wstring();
|
||||
}
|
||||
|
||||
int byteCount = (length < 0) ? static_cast<int>(std::strlen(text)) : length;
|
||||
if (byteCount <= 0)
|
||||
{
|
||||
return std::wstring();
|
||||
}
|
||||
|
||||
int wideCount = MultiByteToWideChar(CP_UTF8, 0, text, byteCount, nullptr, 0);
|
||||
if (wideCount <= 0)
|
||||
{
|
||||
return std::wstring();
|
||||
}
|
||||
|
||||
std::wstring result(static_cast<size_t>(wideCount), L'\0');
|
||||
MultiByteToWideChar(CP_UTF8, 0, text, byteCount, &result[0], wideCount);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string WideToUtf8(const wchar_t* text)
|
||||
{
|
||||
if (text == nullptr)
|
||||
{
|
||||
return std::string();
|
||||
}
|
||||
|
||||
int wideCount = static_cast<int>(std::wcslen(text));
|
||||
if (wideCount <= 0)
|
||||
{
|
||||
return std::string();
|
||||
}
|
||||
|
||||
int byteCount = WideCharToMultiByte(CP_UTF8, 0, text, wideCount, nullptr, 0, nullptr, nullptr);
|
||||
if (byteCount <= 0)
|
||||
{
|
||||
return std::string();
|
||||
}
|
||||
|
||||
std::string result(static_cast<size_t>(byteCount), '\0');
|
||||
WideCharToMultiByte(CP_UTF8, 0, text, wideCount, &result[0], byteCount, nullptr, nullptr);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::wstring Trim(const std::wstring& text)
|
||||
{
|
||||
size_t begin = 0;
|
||||
size_t end = text.size();
|
||||
while (begin < end && IsSpaceChar(text[begin]))
|
||||
{
|
||||
++begin;
|
||||
}
|
||||
while (end > begin && IsSpaceChar(text[end - 1]))
|
||||
{
|
||||
--end;
|
||||
}
|
||||
return text.substr(begin, end - begin);
|
||||
}
|
||||
|
||||
std::wstring StripQuotes(const std::wstring& text)
|
||||
{
|
||||
std::wstring trimmed = Trim(text);
|
||||
if (trimmed.size() >= 2)
|
||||
{
|
||||
const wchar_t first = trimmed.front();
|
||||
const wchar_t last = trimmed.back();
|
||||
if ((first == L'"' && last == L'"') || (first == L'\'' && last == L'\''))
|
||||
{
|
||||
trimmed = trimmed.substr(1, trimmed.size() - 2);
|
||||
}
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
std::wstring ToLowerAscii(const std::wstring& text)
|
||||
{
|
||||
std::wstring result = text;
|
||||
for (wchar_t& c : result)
|
||||
{
|
||||
if (IsUpperAscii(c))
|
||||
{
|
||||
c = static_cast<wchar_t>(c - L'A' + L'a');
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
bool StartsWith(const std::wstring& text, const std::wstring& prefix)
|
||||
{
|
||||
if (prefix.size() > text.size())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return text.compare(0, prefix.size(), prefix) == 0;
|
||||
}
|
||||
|
||||
bool ContainsInsensitive(const std::wstring& text, const std::wstring& needle)
|
||||
{
|
||||
if (needle.empty())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (needle.size() > text.size())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const size_t last = text.size() - needle.size();
|
||||
for (size_t i = 0; i <= last; ++i)
|
||||
{
|
||||
size_t j = 0;
|
||||
while (j < needle.size())
|
||||
{
|
||||
wchar_t a = text[i + j];
|
||||
wchar_t b = needle[j];
|
||||
if (IsUpperAscii(a))
|
||||
{
|
||||
a = static_cast<wchar_t>(a - L'A' + L'a');
|
||||
}
|
||||
if (IsUpperAscii(b))
|
||||
{
|
||||
b = static_cast<wchar_t>(b - L'A' + L'a');
|
||||
}
|
||||
if (a != b)
|
||||
{
|
||||
break;
|
||||
}
|
||||
++j;
|
||||
}
|
||||
if (j == needle.size())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool TryParseDouble(const char* begin, const char* end, double& value)
|
||||
{
|
||||
if (begin == nullptr || end == nullptr || begin >= end)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const char* p = begin;
|
||||
while (p < end && (*p == ' ' || *p == '\t'))
|
||||
{
|
||||
++p;
|
||||
}
|
||||
const char* q = end;
|
||||
while (q > p && (q[-1] == ' ' || q[-1] == '\t' || q[-1] == '\r' || q[-1] == '\n'))
|
||||
{
|
||||
--q;
|
||||
}
|
||||
if (p >= q)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
char buffer[96];
|
||||
const size_t length = static_cast<size_t>(q - p);
|
||||
const char* text = nullptr;
|
||||
std::string dynamic;
|
||||
if (length < sizeof(buffer))
|
||||
{
|
||||
std::memcpy(buffer, p, length);
|
||||
buffer[length] = '\0';
|
||||
text = buffer;
|
||||
}
|
||||
else
|
||||
{
|
||||
dynamic.assign(p, q);
|
||||
text = dynamic.c_str();
|
||||
}
|
||||
|
||||
char* stop = nullptr;
|
||||
const double parsed = std::strtod(text, &stop);
|
||||
if (stop == text)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
value = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
std::wstring FormatNumber(double value, int decimals)
|
||||
{
|
||||
if (decimals < 0)
|
||||
{
|
||||
decimals = 0;
|
||||
}
|
||||
if (decimals > 12)
|
||||
{
|
||||
decimals = 12;
|
||||
}
|
||||
|
||||
wchar_t buffer[96];
|
||||
swprintf(buffer, sizeof(buffer) / sizeof(buffer[0]), L"%.*f", decimals, value);
|
||||
return std::wstring(buffer);
|
||||
}
|
||||
|
||||
std::wstring FormatValue(double value)
|
||||
{
|
||||
if (std::isnan(value))
|
||||
{
|
||||
return L"NaN";
|
||||
}
|
||||
if (std::isinf(value))
|
||||
{
|
||||
return (value > 0.0) ? L"+Inf" : L"-Inf";
|
||||
}
|
||||
|
||||
wchar_t buffer[96];
|
||||
swprintf(buffer, sizeof(buffer) / sizeof(buffer[0]), L"%.6g", value);
|
||||
return std::wstring(buffer);
|
||||
}
|
||||
|
||||
std::wstring FormatTick(double value, double step)
|
||||
{
|
||||
if (value == 0.0)
|
||||
{
|
||||
return L"0";
|
||||
}
|
||||
if (!std::isfinite(value))
|
||||
{
|
||||
return FormatValue(value);
|
||||
}
|
||||
|
||||
int decimals = 0;
|
||||
if (step > 0.0 && std::isfinite(step))
|
||||
{
|
||||
const double magnitude = std::ceil(-std::log10(step) - 1.0e-9);
|
||||
if (magnitude > 0.0)
|
||||
{
|
||||
decimals = static_cast<int>(magnitude);
|
||||
}
|
||||
}
|
||||
if (decimals > 9)
|
||||
{
|
||||
decimals = 9;
|
||||
}
|
||||
|
||||
wchar_t buffer[96];
|
||||
swprintf(buffer, sizeof(buffer) / sizeof(buffer[0]), L"%.*f", decimals, value);
|
||||
std::wstring text(buffer);
|
||||
if (text.size() > 15)
|
||||
{
|
||||
swprintf(buffer, sizeof(buffer) / sizeof(buffer[0]), L"%.4e", value);
|
||||
text.assign(buffer);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
std::wstring FormatCount(unsigned long long value)
|
||||
{
|
||||
wchar_t buffer[32];
|
||||
swprintf(buffer, sizeof(buffer) / sizeof(buffer[0]), L"%llu", value);
|
||||
|
||||
std::wstring digits(buffer);
|
||||
std::wstring result;
|
||||
result.reserve(digits.size() + digits.size() / 3);
|
||||
|
||||
const size_t length = digits.size();
|
||||
for (size_t i = 0; i < length; ++i)
|
||||
{
|
||||
if (i > 0 && ((length - i) % 3u) == 0u)
|
||||
{
|
||||
result.push_back(L',');
|
||||
}
|
||||
result.push_back(digits[i]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
double NiceStep(double range, int targetTicks)
|
||||
{
|
||||
if (!(range > 0.0) || !std::isfinite(range) || targetTicks <= 0)
|
||||
{
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
const double raw = range / static_cast<double>(targetTicks);
|
||||
const double magnitude = std::pow(10.0, std::floor(std::log10(raw)));
|
||||
const double normalized = raw / magnitude;
|
||||
|
||||
double nice = 10.0;
|
||||
if (normalized <= 1.0)
|
||||
{
|
||||
nice = 1.0;
|
||||
}
|
||||
else if (normalized <= 2.0)
|
||||
{
|
||||
nice = 2.0;
|
||||
}
|
||||
else if (normalized <= 5.0)
|
||||
{
|
||||
nice = 5.0;
|
||||
}
|
||||
|
||||
return nice * magnitude;
|
||||
}
|
||||
|
||||
void BuildTicks(double minValue, double maxValue, double step, std::vector<double>& ticks)
|
||||
{
|
||||
ticks.clear();
|
||||
if (!std::isfinite(minValue) || !std::isfinite(maxValue) || !(step > 0.0) || !std::isfinite(step))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (maxValue < minValue)
|
||||
{
|
||||
const double temp = minValue;
|
||||
minValue = maxValue;
|
||||
maxValue = temp;
|
||||
}
|
||||
|
||||
const long long first = static_cast<long long>(std::ceil(minValue / step - 1.0e-9));
|
||||
const long long last = static_cast<long long>(std::floor(maxValue / step + 1.0e-9));
|
||||
if (last < first)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const long long count = last - first + 1;
|
||||
if (count > 512)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ticks.reserve(static_cast<size_t>(count));
|
||||
for (long long index = first; index <= last; ++index)
|
||||
{
|
||||
ticks.push_back(static_cast<double>(index) * step);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace jdi
|
||||
Reference in new issue
Block a user