按组件目录结构把四个巨型实现文件拆分为多个协作文件(PlotView / SignalPanel / Application / DataModel)
承接上一条提交(0871eb0)—— 该提交只完成了 Components/Shared 与注释/命名清理,并**主动未做**
大文件拆分;本提交把大文件拆分补齐,至此“按功能拆分逻辑块到各组件自己的 Inc/Src”已落地。
## 一、拆分结果
| 原文件 | 拆分后 |
| --- | --- |
| Components/PlotView/Src/PlotView.cpp(13 695 行 / 544 KB) | PlotView.cpp(787) + PlotViewPaint.cpp(6 367) + PlotViewInteraction.cpp(2 715) + PlotViewFeatures.cpp(3 005) + PlotViewInternal.hpp(936) |
| Components/SignalPanel/Src/SignalPanel.cpp(4 184 行) | SignalPanel.cpp(1 141) + SignalPanelPaint.cpp(697) + SignalPanelInteraction.cpp(2 095) + SignalPanelInternal.hpp |
| Components/Application/Src/Application.cpp(3 135 行) | Application.cpp(1 427) + ApplicationCommands.cpp(1 184) + ApplicationLoading.cpp(270) + ApplicationInternal.hpp |
| Utilities/Src/DataModel.cpp(2 615 行) | DataModel.cpp(1 583,含自由函数区) + DataModelTab.cpp(732) + DataModelInternal.hpp |
分片职责(以 PlotView 为例):
core 视图核心:构造/析构、上下文、布局、坐标映射、视界与平移、网格结构与归属
paint 渲染与像素缓存:全部绘制、场景/装饰缓存、每网格图层、宽基底与滚动补条
interaction 交互与编辑:命中测试、鼠标/键盘、光标形状、内联输入框、浮动面板层序
features 视图特性与派生数据:光标读数/提示/缩放、自适应、吸附、单点、派生曲线线程池、
边沿统计、实验性功能参数
(SignalPanel 为 core/paint/interaction;Application 为 core/commands/loading;
DataModel 为 core/tab。)
## 二、关键手法(这是能安全拆开的前提)
原文件的辅助区都是**匿名命名空间**(PlotView 约 870 行常量与工具函数),被所有成员函数广泛引用。
直接拆文件会让各 .cpp 拿不到这些工具,因此:
1. 把该块整块搬进 `<组件>Internal.hpp`,从 `namespace { }` 改为**具名**命名空间
(pvdetail / spdetail / appdetail / dmdetail);
2. 块内**每个函数与常量都标 inline** ⇒ 多个翻译单元共用同一个定义:既不重复符号,
也不产生 N 份函数定义;
3. 头部末尾 `using namespace <ns>;` ⇒ 各 .cpp 函数体里原有的非限定调用(IntersectRect /
NiceStep / ClampDouble …)**一行都不用改**;
4. 成员定义按「方法名 → 分片」归类后**整块搬移** —— 全程**纯代码搬移**,没有改动任何一行函数体。
## 三、拆分过程中踩到并修正的四个坑(已固化到 .preflight/perf/split_component.ps1)
1. `[[maybe_unused]]` 等属性**必须排在 inline 之前**(`inline [[x]] T` 非法);
单独的 `{`/`}` 行、注释行、`struct`/`enum`/`using`/`template` 行不能加 inline。
2. 匿名命名空间的**结束行可能带注释**(`} // namespace`),只认 `^\t}$` 会一路匹配到后面
某个成员函数的结束括号,把大段成员定义误当成“辅助区”搬走。
3. **首个成员定义之前的所有行**必须跟着进核心文件:DataModel.cpp 在匿名命名空间之后、
首个成员定义之前还有约 390 行命名空间外的自由函数(SignalStats / ComputeStats /
CacheSparseSamples / ApplySinglePointDefaultStyle …),漏掉会被静默丢弃。
4. 内部头必须带上原文件真正用到的全部头:DataModel 需要 Theme.hpp / <cstring> / <cstdio> /
<deque> / <atomic> / <cwctype> / <new>(原文件靠这些头才编译得过)。
## 四、附带完成的命名审计
PlotView 中残留的 File 语义命名一并修正:LaneSpansMultipleFiles → LaneSpansMultipleGroups、
firstFile → firstGroup、multiFile → multiGroup、SceneCacheKey::haloFile → haloGroup。
## 五、验证(全部离屏:SW_HIDE + 不注入真实输入 + 进程/线程 BELOW_NORMAL)
- .preflight/perf/verify.exe(PlotView 渲染/交互,含逐像素断言)49/49
- .preflight/perf/panelanim.exe(信号面板:卷尺式折起/展开、筛选态、悬浮组名、目标行)156/156
- .preflight/perf/datacheck.exe(数据层:单轴/多时间轴 CSV、MF4、重命名语义)69/69
- .preflight/perf/edgecheck.exe(边沿统计与面板排版)46/46
- make release / make debug 零警告。
即:拆分后**行为与拆分前完全一致**(纯代码搬移,四套探针全绿)。
## 六、体积(诚实记录)
Target.exe: 754 176 → 764 416 B(+10 240 B,+1.4%)
因为 inline 工具函数会在每个引用它的翻译单元里各生成一份(PE/COFF 链接器没有 ICF 合并)。
这再次印证:拆分解决的是**可维护性**,不是体积(本轮的 −24.6% 全部来自 ICO 256×256 条目改 PNG)。
## 七、文档
Documents/agent_mem.md:§9.37 新增“七、多文件拆分(已完成)”与“八、仍未完成”(只剩
Lane → LanePanelState、模态提示解耦、MF4 压缩块三项);§11.2 新增第 91、92 条(多文件化的
唯一安全手法 + 体积代价)。Documents/refactor_report_2026-10-16.md 同步更新 §五/§七/§八。
This commit is contained in:
1 parent
0871eb0cba
commit
e6b7bf66ec
19 files changed
+19130
-18771
No files matched your search
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,312 @@
|
||||
#ifndef __APPLICATION_INTERNAL_HPP__
|
||||
#define __APPLICATION_INTERNAL_HPP__
|
||||
|
||||
// Application 组件内部共享的常量与工具函数(私有头,仅本组件的 .cpp 使用)。
|
||||
//
|
||||
// 本组件的实现按功能拆成多个协作文件,它们共用这一份内部工具集。
|
||||
//
|
||||
// ⚠️ 为让多个翻译单元都能使用,原匿名命名空间改为**具名**命名空间 `appdetail`,
|
||||
// 并把其中每个函数与常量标为 `inline` —— 单一定义、单一副本,不会产生代码重复。
|
||||
// 各实现文件在包含本头之后用 `using namespace appdetail;` 引入这些工具,
|
||||
// 因此函数体里原有的非限定调用一行都不用改。
|
||||
|
||||
#include <windows.h>
|
||||
#include <objidl.h>
|
||||
#include <gdiplus.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include <commdlg.h>
|
||||
#include <psapi.h>
|
||||
#include <shellapi.h>
|
||||
#include <windowsx.h>
|
||||
|
||||
#include "Application.hpp"
|
||||
#include "DataLoader.hpp"
|
||||
#include "ResourceIds.h"
|
||||
#include "Utf8Util.hpp"
|
||||
|
||||
namespace jdi
|
||||
{
|
||||
|
||||
namespace appdetail
|
||||
{
|
||||
/**
|
||||
* @brief Application 内部工具集(常量与自由函数)。
|
||||
*
|
||||
* 全部符号都是 `inline` ⇒ 多个翻译单元共用同一个定义:既不重复符号,也不产生多份代码。
|
||||
* 不要把它们移回匿名命名空间(那样每个 .cpp 都会各自复制一份)。
|
||||
*/
|
||||
inline const wchar_t* kWindowClassName = L"JustDataInspectorMainWindow"; /**< 窗口类名。 */
|
||||
inline const wchar_t* kWindowTitle = L"Just DataInspector"; /**< 窗口标题中的应用名称部分。 */
|
||||
/**
|
||||
* @brief 应用名称与内存占用之间的分隔竖线。
|
||||
*
|
||||
* 与信号分组标题里「组X | 组名」的分隔方式一致:竖线两侧各留出空隙
|
||||
*(`kGroupSeparatorGap` 是那个分隔线的左右间距,这里用两个空格达到同等观感)。
|
||||
*/
|
||||
inline const wchar_t* kTitleSeparator = L" | ";
|
||||
/** @brief 内存占用的显示前缀(与具体数值之间保留一个空格)。 */
|
||||
inline const wchar_t* kRamTitlePrefix = L"RAM : ";
|
||||
inline const float kSplitterWidth = 4.0f; /**< 分隔条宽度(逻辑像素)。 */
|
||||
inline const float kMinPanelWidth = 260.0f; /**< 面板最小宽度(逻辑像素)。 */
|
||||
inline const float kMaxPanelWidth = 400.0f; /**< 面板最大宽度(逻辑像素)。 */
|
||||
inline const int kMinClientWidth = 860; /**< 窗口最小客户区宽度。 */
|
||||
inline const int kMinClientHeight = 540; /**< 窗口最小客户区高度。 */
|
||||
inline const UINT kMessageRemoveGroup = WM_APP + 1; /**< 面板请求移除分组的自定义消息。 */
|
||||
inline const UINT kMessageRemoveSignal = WM_APP + 2; /**< 面板请求移除单个信号的自定义消息。 */
|
||||
inline const UINT_PTR kHintTimerId = 1; /**< 提示浮层淡出用的定时器 ID。 */
|
||||
inline const UINT kHintTimerPeriodMs = 33; /**< 提示浮层淡出的重绘间隔(约 30fps)。 */
|
||||
inline const UINT_PTR kRamTimerId = 2; /**< 标题栏内存占用刷新的定时器 ID。 */
|
||||
inline const UINT kRamTimerPeriodMs = 1000; /**< 标题栏内存占用的刷新间隔(1 秒)。 */
|
||||
/**
|
||||
* @brief 信号列表「滚动到目标信号」动画的定时器 ID。
|
||||
*
|
||||
* 用户 2026-10-15 要求:联动时列表要**平滑滚动**(不是立刻翻到目标位置),
|
||||
* 且"整个动画过程不要太久"。动画本身由 `SignalPanel::AdvanceScrollAnimation()`
|
||||
* 按经过时间推进,这里只负责按帧驱动它(约 60fps)。
|
||||
*/
|
||||
inline const UINT_PTR kScrollTimerId = 3;
|
||||
inline const UINT kScrollTimerPeriodMs = 16;
|
||||
|
||||
/**
|
||||
* @brief 查询本进程当前占用的内存(**私有工作集**,与任务管理器"内存"列同口径)。
|
||||
*
|
||||
* ⚠️ 原先取的是 `PROCESS_MEMORY_COUNTERS::WorkingSetSize`(**总工作集**),它把映射进来的
|
||||
* DLL / 共享页面以及已经释放但尚未被系统回收的驻留页面都算进去 —— 加载一个大文件再
|
||||
* 关闭分组后,实际占用的私有内存已经回到几 MB,标题栏却还显示几十 MB,
|
||||
* 与任务管理器(资源管理器里看到的"内存")对不上(用户报告)。
|
||||
* `PROCESS_MEMORY_COUNTERS_EX2::PrivateWorkingSetSize` 正是任务管理器"内存"列的口径:
|
||||
* 只统计**属于本进程的私有、且驻留在物理内存中**的页面。
|
||||
*
|
||||
* 结构体在这里就地声明(MinGW 的 psapi.h 未提供 `PROCESS_MEMORY_COUNTERS_EX2`),
|
||||
* 布局与 Win32 文档一致;EX2 查询失败(老系统)时退回 `PrivateUsage`(私有提交),
|
||||
* 再不行退回 `WorkingSetSize`,保证标题栏永远有值。
|
||||
*
|
||||
* @return 内存占用字节数;查询失败返回 0。
|
||||
*/
|
||||
inline unsigned long long ProcessPrivateWorkingSetBytes()
|
||||
{
|
||||
struct MemoryCountersEx2
|
||||
{
|
||||
DWORD cb;
|
||||
DWORD PageFaultCount;
|
||||
SIZE_T PeakWorkingSetSize;
|
||||
SIZE_T WorkingSetSize;
|
||||
SIZE_T QuotaPeakPagedPoolUsage;
|
||||
SIZE_T QuotaPagedPoolUsage;
|
||||
SIZE_T QuotaPeakNonPagedPoolUsage;
|
||||
SIZE_T QuotaNonPagedPoolUsage;
|
||||
SIZE_T PagefileUsage;
|
||||
SIZE_T PeakPagefileUsage;
|
||||
SIZE_T PrivateUsage;
|
||||
SIZE_T PrivateWorkingSetSize;
|
||||
SIZE_T SharedCommitUsage;
|
||||
};
|
||||
|
||||
MemoryCountersEx2 ex2 = {};
|
||||
ex2.cb = sizeof(ex2);
|
||||
if (K32GetProcessMemoryInfo(GetCurrentProcess(),
|
||||
reinterpret_cast<PROCESS_MEMORY_COUNTERS*>(&ex2),
|
||||
sizeof(ex2)) != FALSE &&
|
||||
ex2.PrivateWorkingSetSize > 0)
|
||||
{
|
||||
return static_cast<unsigned long long>(ex2.PrivateWorkingSetSize);
|
||||
}
|
||||
|
||||
PROCESS_MEMORY_COUNTERS_EX counters = {};
|
||||
counters.cb = sizeof(counters);
|
||||
if (K32GetProcessMemoryInfo(GetCurrentProcess(),
|
||||
reinterpret_cast<PROCESS_MEMORY_COUNTERS*>(&counters),
|
||||
sizeof(counters)) == FALSE)
|
||||
{
|
||||
return 0ull;
|
||||
}
|
||||
if (counters.PrivateUsage > 0)
|
||||
{
|
||||
return static_cast<unsigned long long>(counters.PrivateUsage);
|
||||
}
|
||||
return static_cast<unsigned long long>(counters.WorkingSetSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 只失效绘图区所在的矩形。
|
||||
*
|
||||
* 配合 WM_PAINT 中对更新区域的判断,可以走“只重绘、只回送绘图区”的快路径;
|
||||
* 绘图区矩形无效时退化为整窗失效,保证不会漏画。
|
||||
*
|
||||
* @param [IN] hwnd 窗口句柄。
|
||||
* @param [IN] plotRect 绘图区矩形(客户区坐标)。
|
||||
*/
|
||||
inline void InvalidatePlotRect(HWND hwnd, const gd::RectF& plotRect)
|
||||
{
|
||||
RECT invalid = {};
|
||||
invalid.left = static_cast<LONG>(std::floor(plotRect.X));
|
||||
invalid.top = static_cast<LONG>(std::floor(plotRect.Y));
|
||||
invalid.right = static_cast<LONG>(std::ceil(plotRect.X + plotRect.Width));
|
||||
invalid.bottom = static_cast<LONG>(std::ceil(plotRect.Y + plotRect.Height));
|
||||
if (invalid.right > invalid.left && invalid.bottom > invalid.top)
|
||||
{
|
||||
InvalidateRect(hwnd, &invalid, FALSE);
|
||||
}
|
||||
else
|
||||
{
|
||||
InvalidateRect(hwnd, nullptr, FALSE);
|
||||
}
|
||||
}
|
||||
|
||||
/** @brief 标签页右键菜单命令 ID。 */
|
||||
inline const int kMenuTabNew = 1;
|
||||
inline const int kMenuTabClose = 2;
|
||||
inline const int kMenuTabCloseOthers = 3;
|
||||
|
||||
/**
|
||||
* @brief 从应用资源中按指定尺寸加载图标。
|
||||
* @param [IN] cx 期望宽度(像素)。
|
||||
* @param [IN] cy 期望高度(像素)。
|
||||
* @return 图标句柄,加载失败返回 nullptr。
|
||||
*/
|
||||
inline HICON LoadAppIcon(int cx, int cy)
|
||||
{
|
||||
HINSTANCE module = GetModuleHandleW(nullptr);
|
||||
// LR_DEFAULTSIZE 之外的显式尺寸请求会挑选 ICO 中最接近的一帧并由系统缩放,
|
||||
// 因此 256/64/48/32/16 的多尺寸资源在任意 DPI 下都能取到合适的一帧。
|
||||
HICON icon = static_cast<HICON>(LoadImageW(module, MAKEINTRESOURCEW(IDI_APP_ICON), IMAGE_ICON,
|
||||
cx, cy, LR_DEFAULTCOLOR));
|
||||
if (icon == nullptr)
|
||||
{
|
||||
icon = static_cast<HICON>(LoadImageW(module, MAKEINTRESOURCEW(IDI_APP_ICON), IMAGE_ICON, 0, 0,
|
||||
LR_DEFAULTCOLOR | LR_DEFAULTSIZE));
|
||||
}
|
||||
return icon;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 后台加载任务的共享状态。
|
||||
*
|
||||
* `done` 是唯一的同步点:工作线程写完其它字段后以 release 语义置位,
|
||||
* 主线程以 acquire 语义读取,因此无需额外锁。
|
||||
*/
|
||||
struct AsyncLoadState
|
||||
{
|
||||
std::atomic<bool> done{false}; /**< 工作线程是否已结束。 */
|
||||
std::atomic<bool> cancelled{false}; /**< 是否请求取消。 */
|
||||
std::atomic<float> progress{0.0f}; /**< 0-1 进度。 */
|
||||
std::atomic<unsigned long long> bytesRead{0}; /**< 已读取字节数(解析阶段)。 */
|
||||
std::atomic<unsigned long long> totalBytes{0}; /**< 文件总字节数(0 表示未知)。 */
|
||||
bool ok = false; /**< 加载是否成功。 */
|
||||
std::wstring error; /**< 失败原因。 */
|
||||
SignalGroup group; /**< 构建结果。 */
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief 工作线程主体:按扩展名选择加载器读入文件并构建信号分组(不接触 DataSet)。
|
||||
* @param [IN,OUT] state 共享状态。
|
||||
* @param [IN] path 文件路径。
|
||||
* @param [IN] paletteBase 调色板起始序号。
|
||||
*/
|
||||
inline void RunAsyncLoad(AsyncLoadState* state, std::wstring path, std::size_t paletteBase)
|
||||
{
|
||||
// 解析阶段占总进度的 0-75%,列重组与统计占 75-100%。
|
||||
LoadProgress parseProgress = [state](const LoadProgressInfo& info)
|
||||
{
|
||||
state->progress.store(static_cast<float>(info.ratio * 0.75), std::memory_order_relaxed);
|
||||
if (info.hasBytes)
|
||||
{
|
||||
state->bytesRead.store(info.bytesRead, std::memory_order_relaxed);
|
||||
state->totalBytes.store(info.totalBytes, std::memory_order_relaxed);
|
||||
}
|
||||
return !state->cancelled.load(std::memory_order_relaxed);
|
||||
};
|
||||
|
||||
LoadedSource source;
|
||||
std::wstring error;
|
||||
if (!LoadSignalSource(path, source, error, parseProgress))
|
||||
{
|
||||
state->error = error;
|
||||
state->done.store(true, std::memory_order_release);
|
||||
return;
|
||||
}
|
||||
|
||||
LoadProgress buildProgress = [state](const LoadProgressInfo& info)
|
||||
{
|
||||
state->progress.store(static_cast<float>(0.75 + info.ratio * 0.25),
|
||||
std::memory_order_relaxed);
|
||||
return !state->cancelled.load(std::memory_order_relaxed);
|
||||
};
|
||||
|
||||
SignalGroup group;
|
||||
if (!BuildSignalGroup(std::move(source), paletteBase, group, error, buildProgress))
|
||||
{
|
||||
state->error = error;
|
||||
state->done.store(true, std::memory_order_release);
|
||||
return;
|
||||
}
|
||||
|
||||
state->group = std::move(group);
|
||||
state->ok = true;
|
||||
state->progress.store(1.0f, std::memory_order_relaxed);
|
||||
state->done.store(true, std::memory_order_release);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 开启进程级 DPI 感知(优先 Per-Monitor V2)。
|
||||
*/
|
||||
inline void EnableDpiAwareness()
|
||||
{
|
||||
HMODULE user32 = GetModuleHandleW(L"user32.dll");
|
||||
if (user32 != nullptr)
|
||||
{
|
||||
typedef BOOL(WINAPI * SetProcessDpiAwarenessContextFn)(HANDLE);
|
||||
SetProcessDpiAwarenessContextFn setContext =
|
||||
reinterpret_cast<SetProcessDpiAwarenessContextFn>(
|
||||
reinterpret_cast<void*>(GetProcAddress(user32, "SetProcessDpiAwarenessContext")));
|
||||
if (setContext != nullptr && setContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) != FALSE)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
SetProcessDPIAware();
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 查询屏幕 DPI。
|
||||
* @return DPI 数值,失败时返回 96。
|
||||
*/
|
||||
inline UINT QuerySystemDpi()
|
||||
{
|
||||
HDC hdc = GetDC(nullptr);
|
||||
if (hdc == nullptr)
|
||||
{
|
||||
return 96u;
|
||||
}
|
||||
const int dpi = GetDeviceCaps(hdc, LOGPIXELSX);
|
||||
ReleaseDC(nullptr, hdc);
|
||||
return (dpi > 0) ? static_cast<UINT>(dpi) : 96u;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 依据 DPI 缩放逻辑像素。
|
||||
* @param [IN] value 逻辑像素值。
|
||||
* @param [IN] scale 缩放比例。
|
||||
* @return 物理像素值。
|
||||
*/
|
||||
inline float ScaleOf(float value, float scale)
|
||||
{
|
||||
return value * scale;
|
||||
}
|
||||
} // namespace appdetail
|
||||
|
||||
using namespace appdetail;
|
||||
|
||||
} // namespace jdi
|
||||
|
||||
#endif // __APPLICATION_INTERNAL_HPP__
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
// Application 实现分片 —— 本文件只包含 Application 的**成员函数定义**(纯代码搬移,行为与拆分前完全一致)。
|
||||
//
|
||||
// 共用的常量与自由函数在 ApplicationInternal.hpp(命名空间 `appdetail`,全部 inline)。
|
||||
// 分片按“职责”划分:核心(状态/布局/几何)、渲染、交互与编辑,见各文件名。
|
||||
#include "ApplicationInternal.hpp"
|
||||
|
||||
namespace jdi
|
||||
{
|
||||
|
||||
bool Application::LoadFile(const std::wstring& path)
|
||||
{
|
||||
if (path.empty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 大文件在工作线程解析并构建,主线程播放加载过渡动画,界面不再整块卡住。
|
||||
SignalGroup group;
|
||||
std::wstring error;
|
||||
if (!LoadFileAsync(path, group, error))
|
||||
{
|
||||
if (!error.empty())
|
||||
{
|
||||
SetStatusMessage(L"解析失败:" + error, true);
|
||||
}
|
||||
InvalidateRect(m_hwnd, nullptr, FALSE);
|
||||
return false;
|
||||
}
|
||||
|
||||
int groupIndex = -1;
|
||||
// 插入新分组会移动 SignalGroup/SignalData 的存储位置,必须先暂停后台派生曲线计算,
|
||||
// 否则后台线程可能正在读取即将被移动的采样数据。
|
||||
m_plot.PauseDerivedAsync();
|
||||
// 此处只做一次指针/缓冲移动,不拷贝任何采样数据,因此不会造成可见卡顿。
|
||||
if (!m_dataSet.AddGroup(std::move(group), groupIndex))
|
||||
{
|
||||
SetStatusMessage(L"解析失败:数据为空。", true);
|
||||
InvalidateRect(m_hwnd, nullptr, FALSE);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 新分组的信号在全部标签页中默认均为未勾选状态,由用户自行决定查看内容。
|
||||
SyncTabsToDataSet();
|
||||
UpdateWindowTitle();
|
||||
|
||||
const SignalGroup* loaded = m_dataSet.GroupAt(groupIndex);
|
||||
if (loaded != nullptr)
|
||||
{
|
||||
// 行数取各时间轴的**最大值**(多时间轴文件的各轴长度可能不同)。
|
||||
std::size_t rowCount = 0;
|
||||
bool anyNonMonotonic = false;
|
||||
std::wstring timeNames;
|
||||
for (const TimeAxis& axis : loaded->axes)
|
||||
{
|
||||
rowCount = std::max(rowCount, axis.Count());
|
||||
anyNonMonotonic = anyNonMonotonic || !axis.monotonic;
|
||||
if (!timeNames.empty())
|
||||
{
|
||||
timeNames += L"、";
|
||||
}
|
||||
timeNames += axis.name;
|
||||
}
|
||||
std::wstring message = L"已加载 组" + std::to_wstring(groupIndex + 1) + L"|" + loaded->name +
|
||||
L"(" + FormatCount(static_cast<unsigned long long>(rowCount)) +
|
||||
L" 行采样," + std::to_wstring(loaded->signals.size()) +
|
||||
L" 条信号," + std::to_wstring(loaded->axes.size()) + L" 条时间轴:" +
|
||||
timeNames + L")";
|
||||
if (anyNonMonotonic)
|
||||
{
|
||||
message += L" 提示:时间列非单调递增,光标取值使用最近邻策略。";
|
||||
}
|
||||
SetStatusMessage(message, false);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Application::LoadFileAsync(const std::wstring& path, SignalGroup& group, std::wstring& error)
|
||||
{
|
||||
error.clear();
|
||||
|
||||
AsyncLoadState state;
|
||||
const std::size_t paletteBase = m_dataSet.SignalCount();
|
||||
|
||||
m_loading = true;
|
||||
m_loadingProgress = 0.0f;
|
||||
m_loadingBytesRead = 0;
|
||||
m_loadingTotalBytes = 0;
|
||||
const std::size_t slashPos = path.find_last_of(L"\\/");
|
||||
m_loadingText = (slashPos == std::wstring::npos) ? path : path.substr(slashPos + 1);
|
||||
|
||||
std::thread worker(RunAsyncLoad, &state, path, paletteBase);
|
||||
|
||||
// 主线程循环:抽送消息 + 刷新动画,直到后台完成或用户取消。
|
||||
while (!state.done.load(std::memory_order_acquire))
|
||||
{
|
||||
MSG message = {};
|
||||
while (PeekMessageW(&message, nullptr, 0, 0, PM_REMOVE))
|
||||
{
|
||||
if (message.message == WM_QUIT)
|
||||
{
|
||||
// 退出请求:让后台停下并重新投递 WM_QUIT,由主循环正常收尾。
|
||||
state.cancelled.store(true, std::memory_order_relaxed);
|
||||
PostQuitMessage(static_cast<int>(message.wParam));
|
||||
break;
|
||||
}
|
||||
// 加载期间屏蔽鼠标与其它按键,避免重入加载或误改数据;
|
||||
// 保留 Esc 作为取消入口。
|
||||
if (message.message >= WM_MOUSEFIRST && message.message <= WM_MOUSELAST)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (message.message == WM_KEYDOWN && message.wParam == VK_ESCAPE)
|
||||
{
|
||||
state.cancelled.store(true, std::memory_order_relaxed);
|
||||
continue;
|
||||
}
|
||||
if (message.message >= WM_KEYFIRST && message.message <= WM_KEYLAST)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
TranslateMessage(&message);
|
||||
DispatchMessageW(&message);
|
||||
}
|
||||
|
||||
m_loadingProgress = state.progress.load(std::memory_order_relaxed);
|
||||
m_loadingBytesRead = state.bytesRead.load(std::memory_order_relaxed);
|
||||
m_loadingTotalBytes = state.totalBytes.load(std::memory_order_relaxed);
|
||||
InvalidateRect(m_hwnd, nullptr, FALSE);
|
||||
UpdateWindow(m_hwnd);
|
||||
Sleep(15);
|
||||
}
|
||||
|
||||
m_loadingProgress = 1.0f;
|
||||
m_loadingBytesRead = m_loadingTotalBytes;
|
||||
worker.join();
|
||||
m_loading = false;
|
||||
|
||||
if (state.cancelled.load(std::memory_order_relaxed))
|
||||
{
|
||||
// 用户主动取消:不弹错误框(与 Esc 取消缩放选区的静默语义一致)。
|
||||
InvalidateRect(m_hwnd, nullptr, TRUE);
|
||||
return false;
|
||||
}
|
||||
if (!state.ok)
|
||||
{
|
||||
error = state.error;
|
||||
return false;
|
||||
}
|
||||
|
||||
group = std::move(state.group);
|
||||
return true;
|
||||
}
|
||||
|
||||
void Application::PaintLoadingOverlay(gd::Graphics& g, int width, int height)
|
||||
{
|
||||
if (!m_loading)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 半透明遮罩压暗整个界面,形成明确的"正在加载"状态。
|
||||
UiFillRect(g, gd::RectF(0.0f, 0.0f, static_cast<float>(width), static_cast<float>(height)),
|
||||
0x66000000u);
|
||||
|
||||
const float cardWidth = std::min(420.0f * m_scale, static_cast<float>(width) - 40.0f * m_scale);
|
||||
const float cardHeight = 132.0f * m_scale;
|
||||
const gd::RectF card((static_cast<float>(width) - cardWidth) * 0.5f,
|
||||
(static_cast<float>(height) - cardHeight) * 0.5f, cardWidth, cardHeight);
|
||||
|
||||
UiFillRoundRect(g, card, 8.0f * m_scale, m_theme.cardBg);
|
||||
UiDrawRoundRect(g, card, 8.0f * m_scale, m_theme.borderStrong, 1.0f);
|
||||
|
||||
// 旋转弧线:用时间驱动的起始角度形成持续的加载动画。
|
||||
const float spinnerRadius = 11.0f * m_scale;
|
||||
const gd::RectF spinner(card.X + 26.0f * m_scale, card.Y + 30.0f * m_scale - spinnerRadius,
|
||||
spinnerRadius * 2.0f, spinnerRadius * 2.0f);
|
||||
m_loadingSpin += 12.0f;
|
||||
if (m_loadingSpin >= 360.0f)
|
||||
{
|
||||
m_loadingSpin -= 360.0f;
|
||||
}
|
||||
gd::Pen trackPen(ToGdiColor(m_theme.border), 3.0f * m_scale);
|
||||
gd::Pen spinPen(ToGdiColor(m_theme.accent), 3.0f * m_scale);
|
||||
g.DrawArc(&trackPen, spinner, 0.0f, 359.0f);
|
||||
g.DrawArc(&spinPen, spinner, m_loadingSpin, 110.0f);
|
||||
|
||||
const float textLeft = card.X + 52.0f * m_scale;
|
||||
const float textWidth = cardWidth - 52.0f * m_scale - 20.0f * m_scale;
|
||||
|
||||
UiText(g, L"正在加载数据…", m_fonts.uiBold,
|
||||
gd::RectF(textLeft, card.Y + 18.0f * m_scale, textWidth, 22.0f * m_scale), m_theme.text,
|
||||
TextAlign::LEFT);
|
||||
|
||||
// 超长文件名用省略号截断,避免撑破卡片。
|
||||
const std::wstring nameText =
|
||||
UiEllipsize(m_loadingText, m_fonts.uiSmall, textWidth);
|
||||
UiText(g, nameText, m_fonts.uiSmall,
|
||||
gd::RectF(textLeft, card.Y + 42.0f * m_scale, textWidth, 20.0f * m_scale),
|
||||
m_theme.textMuted, TextAlign::LEFT);
|
||||
|
||||
// 进度条(复用样式区滑块以外的简单圆角矩形,填充宽度严格按比例)。
|
||||
const gd::RectF track(card.X + 26.0f * m_scale, card.Y + 76.0f * m_scale,
|
||||
cardWidth - 52.0f * m_scale, 8.0f * m_scale);
|
||||
UiFillRoundRect(g, track, track.Height * 0.5f, m_theme.pressed);
|
||||
float ratio = m_loadingProgress;
|
||||
ratio = std::max(0.0f, std::min(ratio, 1.0f));
|
||||
const float fillWidth = track.Width * ratio;
|
||||
if (fillWidth > 1.0f)
|
||||
{
|
||||
UiFillRoundRect(g, gd::RectF(track.X, track.Y, fillWidth, track.Height), track.Height * 0.5f,
|
||||
m_theme.accent);
|
||||
}
|
||||
|
||||
wchar_t buffer[64];
|
||||
swprintf(buffer, sizeof(buffer) / sizeof(buffer[0]), L"%.2f%%", ratio * 100.0);
|
||||
|
||||
// 已读取 / 总大小:两者都只显示数字与自适应单位(GB / MB / KB),均保留两位小数;
|
||||
// 百分比同样是两位小数。总大小未知(如列重组阶段)时只显示百分比。
|
||||
{
|
||||
std::wstring statusText = buffer;
|
||||
if (m_loadingTotalBytes > 0)
|
||||
{
|
||||
statusText = FormatByteSize(m_loadingBytesRead) + L" / " +
|
||||
FormatByteSize(m_loadingTotalBytes) + L" " + buffer;
|
||||
}
|
||||
UiText(g, statusText, m_fonts.monoSmall,
|
||||
gd::RectF(track.X, card.Y + 92.0f * m_scale, track.Width, 20.0f * m_scale),
|
||||
m_theme.text, TextAlign::LEFT);
|
||||
}
|
||||
|
||||
UiText(g, L"按 Esc 取消", m_fonts.uiSmall,
|
||||
gd::RectF(track.X, card.Y + 92.0f * m_scale, track.Width, 20.0f * m_scale),
|
||||
m_theme.textMuted, TextAlign::RIGHT);
|
||||
}
|
||||
|
||||
void Application::LoadCommandLineFiles()
|
||||
{
|
||||
int argumentCount = 0;
|
||||
LPWSTR* arguments = CommandLineToArgvW(GetCommandLineW(), &argumentCount);
|
||||
if (arguments == nullptr)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int loaded = 0;
|
||||
for (int index = 1; index < argumentCount; ++index)
|
||||
{
|
||||
const std::wstring argument = arguments[index];
|
||||
if (argument.empty() || argument[0] == L'-' || argument[0] == L'/')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (LoadFile(argument))
|
||||
{
|
||||
++loaded;
|
||||
}
|
||||
}
|
||||
LocalFree(arguments);
|
||||
|
||||
if (loaded > 0)
|
||||
{
|
||||
SetStatusMessage(L"已加载 " + std::to_wstring(loaded) +
|
||||
L" 个文件,请在左侧勾选需要查看的信号。",
|
||||
false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
} // namespace jdi
|
||||
|
||||
@@ -1019,7 +1019,7 @@ namespace jdi
|
||||
* @param [IN] laneIndex 网格下标。
|
||||
* @return 跨越多个分组返回 true。
|
||||
*/
|
||||
bool LaneSpansMultipleFiles(int laneIndex) const;
|
||||
bool LaneSpansMultipleGroups(int laneIndex) const;
|
||||
|
||||
/**
|
||||
* @brief 获取当前选中信号(跨标签页共享,切换视图后选中项保持一致)。
|
||||
@@ -2413,7 +2413,7 @@ namespace jdi
|
||||
float areaW = 0.0f; /**< 绘图区尺寸。 */
|
||||
float areaH = 0.0f;
|
||||
float scale = 1.0f; /**< DPI 缩放。 */
|
||||
int haloFile = -1; /**< 已绘入缓存的拖拽光晕信号(无则 -1)。 */
|
||||
int haloGroup = -1; /**< 已绘入缓存的拖拽光晕信号(无则 -1)。 */
|
||||
int haloSignal = -1;
|
||||
std::uint64_t contentHash = 0; /**< 信号集合/显示样式/网格幅值等内容指纹。 */
|
||||
};
|
||||
|
||||
+18
-12927
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,932 @@
|
||||
#ifndef __PLOT_VIEW_INTERNAL_HPP__
|
||||
#define __PLOT_VIEW_INTERNAL_HPP__
|
||||
|
||||
// Components/PlotView 组件内部共享的常量与工具函数(私有头,仅本组件的 .cpp 使用)。
|
||||
//
|
||||
// PlotView 的实现按功能拆成多个协作文件:
|
||||
// PlotView.cpp 视图核心(上下文 / 布局 / 坐标映射 / 视界与缩放 / 网格结构)
|
||||
// PlotViewPaint.cpp 渲染与像素缓存(绘制 / 场景与装饰缓存 / 图层 / 宽基底)
|
||||
// PlotViewInteraction.cpp 交互、命中测试与内联编辑
|
||||
// PlotViewFeatures.cpp 实验性功能、派生曲线、边沿统计、自适应与光标吸附
|
||||
// 它们共用这一份内部工具集。
|
||||
//
|
||||
// ⚠️ 为让多个翻译单元都能使用,原匿名命名空间改为**具名**命名空间 `pvdetail`,
|
||||
// 并把其中每个函数与常量标为 `inline` —— 单一定义、单一副本,不会产生代码重复。
|
||||
// 各实现文件在包含本头之后用 `using namespace pvdetail;` 引入这些工具,
|
||||
// 因此函数体里原有的非限定调用(如 `IntersectRect` / `NiceStep`)一行都不用改。
|
||||
|
||||
#include <windows.h>
|
||||
#include <objidl.h>
|
||||
#include <gdiplus.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <cstdint>
|
||||
#include <limits>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "PlotView.hpp"
|
||||
#include "UiControls.hpp"
|
||||
#include "Utf8Util.hpp"
|
||||
|
||||
namespace jdi
|
||||
{
|
||||
|
||||
namespace pvdetail
|
||||
{
|
||||
/**
|
||||
* @brief PlotView 内部工具集(常量 / 几何与配色辅助 / 边沿统计辅助 / 绘制辅助)。
|
||||
*
|
||||
* 全部符号都是 `inline` ⇒ 多个翻译单元共用同一个定义:既不重复符号,也不产生多份代码。
|
||||
* 不要把它们移回匿名命名空间(那样每个 .cpp 都会各自复制一份)。
|
||||
*/
|
||||
inline const int kTimeTickTarget = 10; /**< 时间轴期望刻度数。 */
|
||||
inline const int kYTickTarget = 6; /**< 幅值轴期望刻度数(网格较高时)。 */
|
||||
inline const int kYTickTargetMin = 3; /**< 幅值轴最少刻度数(网格很矮时也要保证可读数)。 */
|
||||
inline const int kYTickTargetMax = 8; /**< 幅值轴最多刻度数。 */
|
||||
inline const float kYTickSpacing = 28.0f; /**< 幅值刻度之间的理想间距(逻辑像素)。 */
|
||||
inline const float kYLabelMinGap = 15.0f; /**< 幅值标签的最小行距(逻辑像素)。 */
|
||||
inline const int kMinorDivisions = 5; /**< 相邻主刻度之间的等分数(用于估读)。 */
|
||||
inline const unsigned int kMinorLineAlpha = 0x9Au; /**< 辅助等分线的透明度(越高越清晰)。 */
|
||||
inline const float kScrollBarThickness = 9.0f; /**< 滚动条宽度(逻辑像素)。 */
|
||||
inline const float kLanePadding = 8.0f; /**< 网格内边距。 */
|
||||
inline const size_t kMaxPolylinePoints = 20000; /**< 单条折线最大顶点数(上限保护)。 */
|
||||
inline const size_t kPolylinePointsPerPixel = 2; /**< 每个像素列允许的折线顶点数(上/下包络两顶点)。 */
|
||||
inline const size_t kMinPolylinePoints = 512; /**< 折线最少顶点数(窗口很窄时仍保留细节)。 */
|
||||
/**
|
||||
* @brief 图层**纵向滚动**的量化阈值(位图像素)。
|
||||
*
|
||||
* 幅值平移不足该值时**不滚动**图层,交给贴图时的纵向偏移(`srcY`)处理;
|
||||
* 超过才滚动一次并补画新暴露的一整条。这样画布映射在拖动过程中保持稳定 →
|
||||
* 折线缓存(键含幅值映射)能命中,避免"每帧滚 1 行 → 每帧重算折线"的性能陷阱。
|
||||
*/
|
||||
inline const int kLayerRollQuantum = 16;
|
||||
inline const float kEnvelopeMinPixelSpan = 0.5f; /**< 包络纵向跨度小于该像素值时合并为单个顶点。 */
|
||||
inline const float kCursorHandleReserve = 9.0f; /**< 光标顶部三角把手占用的高度(数据点贴边时需避让)。 */
|
||||
inline const float kMarkerEdgeInset = 7.0f; /**< 数据点越界贴边时距网格可见边界的间距。 */
|
||||
inline const float kMarkerInsideMargin = 2.0f; /**< 数据点在视界内时允许贴到边界的最小余量。 */
|
||||
inline const float kMarkerTriangleArm = 5.0f; /**< 越界方向三角"半宽/半高"(垂直于朝向的半边长)。 */
|
||||
/** 越界方向三角沿朝向的深度(与光标把手同比例)。 */
|
||||
inline const float kMarkerTriangleDepth = kMarkerTriangleArm * 1.4f;
|
||||
/**
|
||||
* 越界方向三角与数值/时间标注之间的间隙(逻辑像素)。
|
||||
*
|
||||
* 三角与胶囊之间**必须留出这段空白**:贴在一起时三角看起来像胶囊上多出来的
|
||||
* 一个尖角(用户报告的"三角形与数值显示部分没有间隔")。
|
||||
*/
|
||||
inline const float kMarkerTriangleGap = 4.0f;
|
||||
inline const size_t kMaxScatterPoints = 2500; /**< 单条散点最大绘制点数。 */
|
||||
inline const size_t kPolylineCacheLimit = 64; /**< 折线顶点缓存条目上限。 */
|
||||
inline const float kMinScatterRadius = 1.6f; /**< 散点最小半径(逻辑像素)。 */
|
||||
inline const float kMaxScatterRadius = 12.0f; /**< 散点最大半径(逻辑像素)。 */
|
||||
inline const float kScatterRadiusScale = 1.5f; /**< 散点半径相对线宽的增长系数。 */
|
||||
inline const double kMinTimeSpan = 1.0e-12; /**< 允许的最小时间跨度。 */
|
||||
inline const float kPlotSidePadding = 8.0f; /**< 绘图区左右留白(逻辑像素)。 */
|
||||
inline const float kLaneDividerWidth = 2.0f; /**< 网格之间分界线的宽度。 */
|
||||
inline const float kSignalHitTolerance = 6.0f; /**< 信号曲线命中容差(逻辑像素)。 */
|
||||
/**
|
||||
* @brief 两个矩形的交集(空交集返回宽高为 0 的矩形)。
|
||||
*
|
||||
* 用于把 GDI 绘制(不受 GDI+ 裁剪约束)限制在"本次真正要重画的区域"内,
|
||||
* 见 `PaintLane` 里的 `laneClip`。
|
||||
*/
|
||||
inline gd::RectF IntersectRect(const gd::RectF& first, const gd::RectF& second)
|
||||
{
|
||||
const float left = std::max(first.X, second.X);
|
||||
const float top = std::max(first.Y, second.Y);
|
||||
const float right = std::min(first.X + first.Width, second.X + second.Width);
|
||||
const float bottom = std::min(first.Y + first.Height, second.Y + second.Height);
|
||||
if (right <= left || bottom <= top)
|
||||
{
|
||||
return gd::RectF(left, top, 0.0f, 0.0f);
|
||||
}
|
||||
return gd::RectF(left, top, right - left, bottom - top);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 绘制「还原/重置」图标(逆时针回环箭头)。
|
||||
*
|
||||
* 与 `PopupMenu` 里「系数 / 偏移」二级面板标题栏右侧的还原图标**同一套笔画**
|
||||
*(用户要求:“可以直接挪用信号『调整』功能中『系数/偏移』二级菜单内的复位控件”),
|
||||
* 因此弱峰调整面板的复位控件与它视觉完全一致。不依赖字体或资源,任意 DPI 都清晰。
|
||||
*
|
||||
* @param [IN,OUT] g 绘图上下文。
|
||||
* @param [IN] rect 图标外接矩形(正方形)。
|
||||
* @param [IN] color 笔画颜色(0xAARRGGBB)。
|
||||
* @param [IN] scale DPI 缩放比例。
|
||||
*/
|
||||
inline void PaintPanelResetIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t color,
|
||||
float scale)
|
||||
{
|
||||
const float inset = 2.5f * scale;
|
||||
gd::RectF box(rect.X + inset, rect.Y + inset, rect.Width - inset * 2.0f,
|
||||
rect.Height - inset * 2.0f);
|
||||
if (box.Width <= 1.0f || box.Height <= 1.0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
gd::Pen pen(gd::Color((color >> 24) & 0xFFu, (color >> 16) & 0xFFu, (color >> 8) & 0xFFu,
|
||||
color & 0xFFu),
|
||||
std::max(1.0f, 1.5f * scale));
|
||||
pen.SetStartCap(gd::LineCapRound);
|
||||
pen.SetEndCap(gd::LineCapRound);
|
||||
// 缺口留在右上角(从 300° 到 20° 处断开),与常见的"重置/刷新"图标一致。
|
||||
g.DrawArc(&pen, box.X, box.Y, box.Width, box.Height, 300.0f, 300.0f);
|
||||
|
||||
// 箭头:位于缺口末端(约 300° 方向),指向前进方向。
|
||||
const float cx = box.X + box.Width * 0.5f;
|
||||
const float cy = box.Y + box.Height * 0.5f;
|
||||
const float radius = box.Width * 0.5f;
|
||||
const float angle = 300.0f * 3.14159265f / 180.0f;
|
||||
const float tipX = cx + std::cos(angle) * radius;
|
||||
const float tipY = cy + std::sin(angle) * radius;
|
||||
const float size = std::max(2.5f, 3.2f * scale);
|
||||
gd::PointF arrow[3];
|
||||
arrow[0] = gd::PointF(tipX, tipY);
|
||||
arrow[1] = gd::PointF(tipX - size, tipY - size * 0.25f);
|
||||
arrow[2] = gd::PointF(tipX - size * 0.25f, tipY - size);
|
||||
gd::SolidBrush brush(gd::Color((color >> 24) & 0xFFu, (color >> 16) & 0xFFu,
|
||||
(color >> 8) & 0xFFu, color & 0xFFu));
|
||||
g.FillPolygon(&brush, arrow, 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 光标吸附的**判定系数**:一屏内的数据点少于 `网格小格数 × 该系数` 时才开启吸附。
|
||||
*
|
||||
* 用户 2026-10-15 明确定义:网格底衬一横排有 N 个小格(见 `GridMinorCellCount`),
|
||||
* **当且仅当**某信号在当前视界内的数据点个数 `< N × 4` 时为它开启光标吸附。
|
||||
* 该判据随视界实时重算 ⇒ 疏密变化的信号在稀疏区自动吸附、稠密区自动自由移动。
|
||||
*/
|
||||
inline const int kSnapVisibleCellsPerPoint = 4;
|
||||
|
||||
/**
|
||||
* @brief 吸附半径(以**网格小格**为单位):一个数据点只用其**一个小格宽**内的移动来吸。
|
||||
*
|
||||
* 吸附半径 = 一个网格小格的宽度(与判定用同一套网格定义),且**不超过半个采样间距**
|
||||
* ——后者保证点很稀疏时(例如一屏只有一两个点)光标在点与点之间仍可自由移动,
|
||||
* 而不是被“死死吸住”(用户明确要求:吸附只是提示性响应)。
|
||||
*/
|
||||
inline const double kSnapRadiusCells = 1.0;
|
||||
|
||||
/**
|
||||
* @brief 一般信号查找“离光标最近的有效采样点”时向两侧探测的最大步数。
|
||||
*
|
||||
* 采样值里的 NaN 只出现在缺少数据的区段(例如多时间轴 CSV 的较短轴的尾部),
|
||||
* 因此从最近下标向外少量探测即可找到有效点;探测失败就不吸附(宁可自由移动)。
|
||||
*/
|
||||
inline const int kSnapSampleProbeSteps = 64;
|
||||
|
||||
/**
|
||||
* @brief 判定“可见数据点是否足够少”时允许扫描的最大样本数(相对阈值的倍数)。
|
||||
*
|
||||
* 判定只需知道“有效点是否少于阈值”,因此扫到阈值个有效点就早退;
|
||||
* 但连续 NaN 不会被计入,因此再给一个**有界**的扫描窗口防止极端情况下的长循环。
|
||||
*/
|
||||
inline const std::size_t kSnapScanWindowFactor = 64;
|
||||
|
||||
/**
|
||||
* @brief 「所有可见数据点都是单值信号、且时刻完全相同」时,时间轴**一个大格**对应的秒数。
|
||||
*
|
||||
* 用户 2026-10-15 明确要求:这种最极端情况下时间轴以该时刻居中、并缩放到
|
||||
* **一大格 = 1s** 的级别。视界跨度取 `kTimeTickTarget` 个大格 ⇒
|
||||
* 跨度 = 10s、`NiceStep(10, kTimeTickTarget)` = **1.0 秒**,正好是"一大格 1s"。
|
||||
*/
|
||||
inline const double kSinglePointClusterSecondsPerCell = 1.0;
|
||||
|
||||
/**
|
||||
* @brief 让边界处的单值散点"完整显示"时,在半径之外额外多留的像素(设备像素)。
|
||||
*
|
||||
* 只让出**正好等于半径**的量会让圆点边缘贴着网格边框(抗锯齿边缘看起来被切了一刀),
|
||||
* 因此再多留 1 个像素。
|
||||
*/
|
||||
inline const float kSinglePointBoundaryPadPixels = 1.0f;
|
||||
|
||||
/**
|
||||
* @brief 判定"多个单值数据点处于同一时刻"的相对误差。
|
||||
*
|
||||
* 同一列里读出的时刻是完全相同的 double;但不同来源(例如 MF4 的整数刻度 ×
|
||||
* 通道组的标定系数)可能差 1ulp,因此用相对误差而不是精确相等。
|
||||
*/
|
||||
inline const double kSinglePointSameTimeEpsilon = 1.0e-9;
|
||||
|
||||
inline const float kLaneLabelPad = 6.0f; /**< 网格内侧标尺带的内边距。 */
|
||||
inline const float kLegendHeight = 19.0f; /**< 图例条目高度(逻辑像素)。 */
|
||||
inline const float kLegendPad = 5.0f; /**< 图例相对网格左上角的内缩。 */
|
||||
inline const float kLegendSwatchSize = 8.0f; /**< 图例色块尺寸。 */
|
||||
inline const float kLegendItemGap = 14.0f; /**< 图例条目之间的间距。 */
|
||||
inline const float kLegendMaxTextWidth = 200.0f; /**< 图例条目的文本宽度上限(超出则省略)。 */
|
||||
inline const float kLaneBadgeWidth = 30.0f; /**< 网格编号徽标宽度(与信号面板保持一致)。 */
|
||||
inline const float kLaneBadgeHeight = 17.0f; /**< 网格编号徽标高度。 */
|
||||
inline const float kLaneBadgeGap = 8.0f; /**< 编号徽标与图例条目之间的间距。 */
|
||||
inline const float kEdgePanelWidth = 230.0f; /**< 边沿统计浮动面板宽度。 */
|
||||
inline const float kEdgePanelTitleHeight = 26.0f; /**< 面板标题栏高度。 */
|
||||
inline const float kEdgePanelRowHeight = 18.0f; /**< 面板内单行文本高度。 */
|
||||
inline const float kEdgePanelPad = 7.0f; /**< 面板内边距。 */
|
||||
inline const float kEdgePanelNameMaxWidth = 150.0f; /**< 面板内信号名的宽度上限(超过则省略;避免超长信号名把面板顶得很长)。 */
|
||||
inline const float kEdgePanelStatColumnGap = 14.0f; /**< 「上升沿 / 下降沿」两列之间的间距(保证多信号的文字垂直对齐)。 */
|
||||
inline const float kEdgePanelEditFieldWidth = 64.0f; /**< 数值框的最小宽度。 */
|
||||
inline const float kEdgePanelButtonHeight = 30.0f; /**< 均值滤波面板底部按钮行高度。 */
|
||||
inline const float kEdgePanelMeanRowHeight = 22.0f; /**< 逐条目面板中每条信号的名称行高度。 */
|
||||
inline const float kEdgePanelMeanEditHeight = 24.0f; /**< 逐条目面板中每条信号的参数行高度。 */
|
||||
inline const float kEdgePanelMeanRowGap = 2.0f; /**< 名称行与参数行之间的间距。 */
|
||||
inline const float kEdgePanelMeanCheckSize = 15.0f; /**< 逐条目面板中条目复选框尺寸。 */
|
||||
inline const float kWeakPeakSliderRowHeight = 26.0f; /**< 弱峰调整面板中每个滑块行的高度。 */
|
||||
inline const float kWeakPeakTrackHeight = 6.0f; /**< 弱峰调整滑块的轨道高度。 */
|
||||
inline const float kWeakPeakTrackMinWidth = 120.0f; /**< 弱峰调整滑块轨道的最小宽度。 */
|
||||
/**
|
||||
* @brief 「弱峰调整」滑块行右侧**复位控件**的边长(逻辑像素)。
|
||||
*
|
||||
* 每行滑块都有一个复位按钮(与「系数/偏移」二级面板里的「还原」图标同一套绘制),
|
||||
* 点击把该行的参数恢复为默认值(幅度 0 / 迭代次数 1 且上限回到初始上限)。
|
||||
*/
|
||||
inline const float kWeakPeakResetButtonSize = 16.0f;
|
||||
/** 复位控件与其左侧数值列之间的间距(逻辑像素)。 */
|
||||
inline const float kWeakPeakResetGap = 4.0f;
|
||||
inline const int kWeakPeakIterationsMin = 1; /**< 迭代次数 N 的下限。 */
|
||||
/** 迭代次数滑块「自定义上限」的允许范围(与算法内部的阶数夹取保持一致)。 */
|
||||
inline const int kWeakPeakIterationsMaxLimit = 1000;
|
||||
/** 迭代次数滑块上限的默认值(与 `Lane::WeakPeakItem::maxIterations` 一致)。 */
|
||||
inline const int kWeakPeakIterationsDefaultMax = 10;
|
||||
/**
|
||||
* @brief 「弱峰调整」滑动条的量程(±400%)。
|
||||
*
|
||||
* 滑动条同时驱动三组识别规则(见 `ComputeWeakPeakAdjust`):0–100% 只有第一类规则在动,
|
||||
* 之后每 100% 加入下一类、而前一类继续走完自己的 0–200%。因此量程是算法内部
|
||||
* 「单组 ±200%」的两倍。
|
||||
*/
|
||||
inline const double kWeakPeakSliderScale = 4.0;
|
||||
/** 弱峰调整幅度百分比上限(滑动条右端,用于构建新信号 / 曲线缓存键)。 */
|
||||
inline const int kWeakPeakAmountMax = 400;
|
||||
/** 弱峰调整幅度百分比下限(滑动条左端,负值 = 削弱)。 */
|
||||
inline const int kWeakPeakAmountMin = -400;
|
||||
/**
|
||||
* @brief 派生曲线「区间窗口」缓存的保护带上限(采样点数)。
|
||||
*
|
||||
* 窗口只覆盖可见区间 ± 保护带,可见区间内的取值与整条曲线一致(见 `CurveSpanFor`)。
|
||||
* 上限避免在完全缩小时把保护带放大到整条信号(那就退化成重算整条曲线)。
|
||||
*/
|
||||
inline const std::size_t kDerivedCurveGuardMax = 65536;
|
||||
/** 派生曲线缓存条目上限(超出时按「最旧优先」淘汰;它只是加速与内存控制手段)。 */
|
||||
inline const std::size_t kCurveSpanCacheLimit = 32;
|
||||
/** 派生曲线缓存允许占用的总字节数(约 768MB):超出时按「最旧优先」淘汰。
|
||||
* 取足够大是为了让「完全放大时 5 个网格各缓存一条整段曲线」可以同时驻留。 */
|
||||
inline const std::size_t kCurveSpanCacheMaxBytes = 768ull * 1024ull * 1024ull;
|
||||
/**
|
||||
* @brief 派生(预览)曲线走「后台异步计算」的窗口采样数门槛。
|
||||
*
|
||||
* 拖动滑块 / 移动视界 / 缩放每帧都会请求预览曲线。窗口不超过本门槛时同步重算足够快
|
||||
* (弱峰调整 ≈0.9ms/6.5 万采样),预览曲线能**实时跟随控件**;一旦窗口超过门槛
|
||||
* (大时间跨度、整条信号),同步重算会阻塞交互(弱峰调整整条信号单条 ≈368ms),
|
||||
* 此时改为交后台线程计算、本帧先用旧曲线顶替(双缓冲),保证控件反馈始终跟手。
|
||||
*/
|
||||
inline const std::size_t kDerivedAsyncWindowThreshold = 65536;
|
||||
/**
|
||||
* @brief 「超大信号」门槛:信号采样数超过它时,**拖拽参数期间不再反复排队**"整条信号"的
|
||||
* 预览曲线重算(见 `CurveSpanFor`)。
|
||||
*
|
||||
* 预览曲线按整条信号计算(`[0, count]`,与「构建为新信号」逐位一致)。信号极大时每条
|
||||
* 曲线要扫描上亿个采样,后台线程被持续占满会**反过来拖慢前台绘制**(实测前台 14ms/帧,
|
||||
* 而绘制本身仅 0.4ms)。此时拖动期间沿用旧曲线、松手后再算一次最新的即可 ——
|
||||
* 整条信号的重算本来就追不上滑块速度,"冻结"与"每帧重排"观感相同,但后者拖慢前台。
|
||||
*/
|
||||
inline const std::size_t kHugeSignalPreviewSamples = 4000000ull;
|
||||
inline const wchar_t* const kWeakPeakLevelLabel = L"调整幅度"; /**< 逐条目面板里「幅度」滑块行的标签(用户要求:滑动条前不再单独显示 `%`)。 */
|
||||
inline const wchar_t* const kWeakPeakIterationsLabel = L"迭代次数 N"; /**< MEAN / CENTER 面板里「迭代次数」输入框的标签。 */
|
||||
/** 「弱峰调整」迭代次数滑块行的标签(用户要求:滑动条前不再单独显示 `N`)。 */
|
||||
inline const wchar_t* const kWeakPeakIterationsSliderLabel = L"迭代次数";
|
||||
inline const wchar_t* const kWeakPeakTitle = L"弱峰调整"; /**< 弱峰调整面板标题。 */
|
||||
inline const wchar_t* const kBuildSignalLabel = L"构建为新信号"; /**< 逐条目面板底部按钮文案(均值滤波 / 弱峰调整共用)。 */
|
||||
/**
|
||||
* @brief 边沿统计累积状态上限。
|
||||
*
|
||||
* ⚠️ 必须足够大:`普通 + 中心轴` × 一个网格里的信号数(都勾选时)会被全部使用。
|
||||
* 上限太小会**淘汰其它信号的已扫完状态** → 它们从头重扫(表现为“改一个参数、
|
||||
* 其它几个信号也重新统计”)。一个状态仅占「块数组 ≈ (采样数/8192)×16B」+ 几十 KB
|
||||
* 本地曲线,64 个状态对内存无压力。
|
||||
* 真的超限时**优先淘汰「已扫完」的最旧状态**(它重新扫完很快),绝不淘汰正在推进的。
|
||||
*/
|
||||
inline const std::size_t kEdgeScanStateLimit = 64;
|
||||
/**
|
||||
* @brief 「边沿计数」累积统计的分块大小(采样点数)。
|
||||
*
|
||||
* 每块各存上升/下降沿数:查询某个时间区间时,内部整块用存储值相加、只有两端边界块
|
||||
* 需要就地精确扫描(≤ 2 块),因此拖动时间光标的单帧代价与区间长度无关。
|
||||
* 取小一点 ⇒ 边界块扫描更便宜;取大一点 ⇒ 块数组更省内存。
|
||||
*/
|
||||
inline const std::size_t kEdgeBlockSamples = 8192;
|
||||
/**
|
||||
* @brief 每一帧允许「边沿计数」扫描的采样对总数(跨全部累积状态共享)。
|
||||
*
|
||||
* 千万级信号整段扫描一次 ≈115ms(用户报告:5 条信号同一网格时单帧 ≈575ms,
|
||||
* 界面完全失去响应)。改成预算推进后,单帧最多只花 ≈5ms 在统计上,
|
||||
* 剩下的部分在后续帧里接着扫(未扫完时界面显示上一个完整结果 + “…”)。
|
||||
*
|
||||
* ⚠️ **必须按帧重置**(在 `Paint` 开头):旧实现按 `GetTickCount64()` 分桶,
|
||||
* 而系统时钟粒度约 15.6ms —— 当帧率高于它时(`PostMessage` 自持重绘会把帧率拉高),
|
||||
* 同一 tick 内的多帧**完全不推进**(用户报告:“按住横向光标不动时统计卡在统计中”,
|
||||
* “回车确认参数后不移动鼠标就一直停在部分值”)。
|
||||
*/
|
||||
inline const std::size_t kEdgeScanPairsPerFrame = 1200000;
|
||||
/**
|
||||
* @brief 每个累积状态每帧的扫描配额下限(防止多个状态时前面的把预算吃光)。
|
||||
*
|
||||
* 过去所有状态共享一个“本 tick 总预算”,而推进总是从列表中的第一个状态开始
|
||||
* —— 5 条信号时只有第一条有进展,其余 4 条长期显示“统计中…”(用户报告
|
||||
* “很多项都停在了统计中不更新”)。现在每个状态各自持有每帧配额,互不饿死。
|
||||
*/
|
||||
inline const std::size_t kEdgeScanPairsPerStateMin = 60000;
|
||||
/**
|
||||
* @brief 计算每状态配额时假定的“最小并发状态数”。
|
||||
*
|
||||
* ⚠️ 不能直接用 `m_edgeScans.size()`:状态是在**同一帧内逐个创建**的,第一个状态的
|
||||
* 那一刻 `size() == 1`,会拿走整额预算 → 同帧后面新建的状态全部被截断为 0 配额
|
||||
*(首帧只有第一条信号推进)。按一个典型并发量(普通 + 中心轴 × 网格内信号数)
|
||||
* 下限分摊,可保证同一帧里所有状态都得到推进。
|
||||
*/
|
||||
inline const std::size_t kEdgeScanFairShare = 6;
|
||||
/**
|
||||
* @brief 「边沿计数」结果里的**上升沿**部分文本。
|
||||
* @param [IN] rising 上升沿数量。 @param [IN] complete 是否为完整精确值。
|
||||
* @return 形如 "上升沿 123"(未算完时追加 " …")的文本。
|
||||
*/
|
||||
inline std::wstring EdgeRisingText(std::size_t rising, bool complete)
|
||||
{
|
||||
std::wstring text = L"上升沿 " + std::to_wstring(rising);
|
||||
if (!complete)
|
||||
{
|
||||
text += L" …";
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 「边沿计数」结果里的**下降沿**部分文本。
|
||||
* @param [IN] falling 下降沿数量。 @param [IN] complete 是否为完整精确值。
|
||||
* @return 形如 "下降沿 456"(未算完时追加 " …")的文本。
|
||||
*/
|
||||
inline std::wstring EdgeFallingText(std::size_t falling, bool complete)
|
||||
{
|
||||
std::wstring text = L"下降沿 " + std::to_wstring(falling);
|
||||
if (!complete)
|
||||
{
|
||||
text += L" …";
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 「边沿计数」是否处于“完全未算出”状态(用于显示“统计中…”)。
|
||||
*/
|
||||
inline bool EdgeCountsPending(std::size_t rising, std::size_t falling, bool complete)
|
||||
{
|
||||
return (!complete && rising == 0 && falling == 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 「边沿计数」结果的显示文本(合并成一行,用于宽度测量与兼容旧调用)。
|
||||
*
|
||||
* ⚠️ 未完成时**两个数字后面都要带 `…`**(旧实现只在整行末尾追加一个 `…`,
|
||||
* 视觉上只跟在“下降沿”的数字后面,用户明确要求“为上升沿也加上这个尾缀”)。
|
||||
* @param [IN] rising 上升沿数量。 @param [IN] falling 下降沿数量。
|
||||
* @param [IN] complete 是否为该阈值的完整精确值。
|
||||
* @return 面板/宽度测量共用的文本。
|
||||
*/
|
||||
[[maybe_unused]] inline std::wstring EdgeCountsText(std::size_t rising, std::size_t falling,
|
||||
bool complete)
|
||||
{
|
||||
if (EdgeCountsPending(rising, falling, complete))
|
||||
{
|
||||
return L"统计中…";
|
||||
}
|
||||
return EdgeRisingText(rising, complete) + L" " + EdgeFallingText(falling, complete);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 判断坐标是否命中「弱峰调整」的某条滑块轨道。
|
||||
*
|
||||
* 轨道本身只有几个像素高,直接按轨道矩形命中很难抓住,因此把命中区在纵向上下各放宽
|
||||
* (至少一个轨道高度),与样式区滑块的交互一致。
|
||||
*
|
||||
* @param [IN] track 滑块轨道矩形(宽度为 0 表示当前没有滑块)。
|
||||
* @param [IN] scale DPI 缩放比例。
|
||||
* @param [IN] x 客户区横坐标。
|
||||
* @param [IN] y 客户区纵坐标。
|
||||
* @return 命中返回 true。
|
||||
*/
|
||||
inline bool WeakPeakSliderHit(const gd::RectF& track, float scale, float x, float y)
|
||||
{
|
||||
if (track.Width <= 0.0f)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
gd::RectF hit = track;
|
||||
const float pad = std::max(8.0f * scale, track.Height);
|
||||
hit.Y -= pad;
|
||||
hit.Height += pad * 2.0f;
|
||||
return UiPointInRect(hit, x, y);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 「弱峰调整」滑块行右侧**数值列**的宽度(布局与面板宽度共用同一算式)。
|
||||
*
|
||||
* 取「+400%」与「10000」两者较宽者:前者是幅度滑块的最大显示文本
|
||||
*(**正负号始终显示**,且量程已扩到 ±400%),
|
||||
* 后者为迭代次数上限留出足够位宽(上限可被用户改大)。
|
||||
*/
|
||||
inline float WeakPeakValueColumnWidth(HFONT monoSmall)
|
||||
{
|
||||
return std::max(UiMeasureTextWidth(monoSmall, L"+400%"),
|
||||
UiMeasureTextWidth(monoSmall, L"10000"));
|
||||
}
|
||||
|
||||
/** @brief 「弱峰调整」滑块行左侧**标签列**的宽度(两条标签取较宽者,保证轨道左对齐)。 */
|
||||
inline float WeakPeakLabelColumnWidth(HFONT uiSmall)
|
||||
{
|
||||
return std::max(UiMeasureTextWidth(uiSmall, kWeakPeakLevelLabel),
|
||||
UiMeasureTextWidth(uiSmall, kWeakPeakIterationsSliderLabel));
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 取折线(相邻采样点之间线性连接)在给定时刻的取值。
|
||||
*
|
||||
* 包络是按像素列聚合的,因此某一段折线可能**跨越列边界**:跨越边界的那一小段
|
||||
* 在两个列里都不属于"落在本列内的采样点",只看列内采样点就会把它整段丢掉。
|
||||
* 该函数用于在列边界处取回这段折线的取值,使相邻两列的包络严丝合缝。
|
||||
*
|
||||
* @param [IN] time 时间轴。
|
||||
* @param [IN] values 采样值。
|
||||
* @param [IN] count 可用采样点数量。
|
||||
* @param [IN] style 显示外观(水平镜像时按**源下标反序**取值)。
|
||||
* @param [IN] firstIndex 第一个时间不小于查询时刻的采样下标(相交线段为 firstIndex-1 → firstIndex)。
|
||||
* @param [IN] t 查询时刻。
|
||||
* @param [OUT] out 输出取值。
|
||||
* @return 折线覆盖该时刻且取到有效值返回 true。
|
||||
*/
|
||||
inline bool PolylineValueAt(const std::vector<double>& time, const std::vector<double>& values,
|
||||
std::size_t count, const SignalStyle& style, std::size_t firstIndex,
|
||||
double t, double& out)
|
||||
{
|
||||
if (firstIndex == 0 || firstIndex >= count)
|
||||
{
|
||||
// 该时刻之前(或之后)没有采样点,折线并未延伸到这里。
|
||||
return false;
|
||||
}
|
||||
const double t0 = time[firstIndex - 1];
|
||||
const double t1 = time[firstIndex];
|
||||
// 水平镜像时画面上的相邻两点取源数组中头尾对调后的两个采样。
|
||||
const double v0 = values[SourceIndexFor(style, count, firstIndex - 1)];
|
||||
const double v1 = values[SourceIndexFor(style, count, firstIndex)];
|
||||
if (!std::isfinite(t0) || !std::isfinite(t1) || !std::isfinite(v0) || !std::isfinite(v1) ||
|
||||
!(t1 > t0))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
double ratio = (t - t0) / (t1 - t0);
|
||||
if (ratio < 0.0)
|
||||
{
|
||||
ratio = 0.0;
|
||||
}
|
||||
else if (ratio > 1.0)
|
||||
{
|
||||
ratio = 1.0;
|
||||
}
|
||||
out = v0 + (v1 - v0) * ratio;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 统计相邻采样对 (k-1, k) 中穿越判定基准的边沿数量。
|
||||
*
|
||||
* `deltaAt(k)` 返回第 k 个采样相对判定基准的偏差;返回非有限值(NaN)表示该采样
|
||||
* 不参与判定,并**打断连续性**(与前一点之间不计边沿)——这正好覆盖
|
||||
* 「采样缺失」与「采样落在统计时间区间之外」两种情况。
|
||||
*
|
||||
* 统计的是采样对 `k ∈ [pairFirst, pairLast]`(闭区间),每对的「前一点」为 `k-1`,
|
||||
* 因此起点对的连续性由 `deltaAt(pairFirst - 1)` 决定(它无效时从下一个有效点重新起算)。
|
||||
*
|
||||
* 该函数是增量统计的基本单位:只要把区间切成互不相交的若干段分别累加(或从总量中减去),
|
||||
* 结果与一次性扫描整段完全一致,因此拖动光标时可以只扫描两端差集。
|
||||
*
|
||||
* @param [IN] pairFirst 起始采样对的下标 k(含),必须 ≥ 1。
|
||||
* @param [IN] pairLast 结束采样对的下标 k(含);小于起点时不做任何事。
|
||||
* @param [IN] deltaAt 偏差取值仿函数。
|
||||
* @param [IN,OUT] rising 累加的上升沿数量。
|
||||
* @param [IN,OUT] falling 累加的下降沿数量。
|
||||
*/
|
||||
template <typename DeltaAt>
|
||||
inline void AccumulateEdgeCrossings(std::size_t pairFirst, std::size_t pairLast, DeltaAt deltaAt,
|
||||
std::size_t& rising, std::size_t& falling)
|
||||
{
|
||||
if (pairFirst == 0 || pairFirst > pairLast)
|
||||
{
|
||||
return;
|
||||
}
|
||||
double previousDelta = deltaAt(pairFirst - 1);
|
||||
bool hasPrevious = std::isfinite(previousDelta);
|
||||
for (std::size_t index = pairFirst; index <= pairLast; ++index)
|
||||
{
|
||||
const double current = deltaAt(index);
|
||||
if (!std::isfinite(current))
|
||||
{
|
||||
hasPrevious = false;
|
||||
continue;
|
||||
}
|
||||
if (hasPrevious)
|
||||
{
|
||||
if (previousDelta < 0.0 && current >= 0.0)
|
||||
{
|
||||
++rising;
|
||||
}
|
||||
else if (previousDelta > 0.0 && current <= 0.0)
|
||||
{
|
||||
++falling;
|
||||
}
|
||||
}
|
||||
previousDelta = current;
|
||||
hasPrevious = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 「调整」变换 + 水平镜像的指纹(用于边沿统计缓存键)。
|
||||
*
|
||||
* 边沿判定基于**画出来的曲线**,因此任何一个变换项改变都可能改变计数结果;
|
||||
* 把这些项折叠成一个指纹后,样式一变就能立刻失效缓存项(否则会沿用变换前的旧计数)。
|
||||
*
|
||||
* @param [IN] style 信号外观。
|
||||
* @return 指纹值。
|
||||
*/
|
||||
inline std::uint64_t EdgeStyleKey(const SignalStyle& style)
|
||||
{
|
||||
std::uint64_t key = 1469598103934665603ull;
|
||||
const auto mix = [&key](std::uint64_t value)
|
||||
{
|
||||
key ^= value;
|
||||
key *= 1099511628211ull;
|
||||
};
|
||||
mix(static_cast<std::uint64_t>(
|
||||
std::llround(static_cast<double>(style.gain) * 1.0e6)));
|
||||
mix(static_cast<std::uint64_t>(
|
||||
std::llround(static_cast<double>(style.offsetY) * 1.0e6)));
|
||||
mix(style.absolute ? 1u : 0u);
|
||||
mix(style.mirrorVertical ? 2u : 0u);
|
||||
mix(style.mirrorHorizontal ? 4u : 0u);
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 计算统计区间对应的采样下标区间 [begin, end)。
|
||||
*
|
||||
* 时间轴单调时用二分定位。⚠️ 区间**之外**的采样一律不参与计数,
|
||||
* 因此这里**不做任何外扩**:只有两端都落在 `[begin, end)` 内的采样对才会被累计
|
||||
*(调用方从 `begin + 1` 开始枚举采样对)。
|
||||
*
|
||||
* @param [IN] group 所属数据分组。
|
||||
* @param [IN] count 采样点数量。
|
||||
* @param [IN] hasRange 是否限定时间区间。
|
||||
* @param [IN] rangeMin 区间左端点时间。
|
||||
* @param [IN] rangeMax 区间右端点时间。
|
||||
* @param [OUT] begin 输出区间起点(含)。
|
||||
* @param [OUT] end 输出区间终点(不含)。
|
||||
* @return 计数是否完全由该下标区间决定(时间轴单调时为真)。
|
||||
*/
|
||||
inline bool EdgeIndexRange(const TimeAxis& axis, std::size_t count, bool hasRange, double rangeMin,
|
||||
double rangeMax, std::size_t& begin, std::size_t& end)
|
||||
{
|
||||
begin = 0;
|
||||
end = count;
|
||||
if (!hasRange)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (!axis.monotonic)
|
||||
{
|
||||
// 时间轴非单调时无法用下标区间表达统计范围(需逐点按时间过滤)。
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::vector<double>& times = axis.time;
|
||||
const std::size_t timeCount = std::min(times.size(), count);
|
||||
begin = static_cast<std::size_t>(
|
||||
std::lower_bound(times.begin(), times.begin() + static_cast<std::ptrdiff_t>(timeCount),
|
||||
rangeMin) -
|
||||
times.begin());
|
||||
// ⚠️ **不要**在左端多取一个采样点:那会让“跨越区间边界的边沿”也算进来,
|
||||
// 而用户明确要求“在光标选区外的点不要算入计数规则”。
|
||||
// (例:光标恰好落在越过阈值的第一个采样点 B 上时,A→B 这一段处于选区之外,
|
||||
// 不计入;旧实现在左端多取一个点,于是 B 被错认成上升沿。)
|
||||
end = static_cast<std::size_t>(
|
||||
std::upper_bound(times.begin(), times.begin() + static_cast<std::ptrdiff_t>(timeCount),
|
||||
rangeMax) -
|
||||
times.begin());
|
||||
end = std::min(end, timeCount);
|
||||
begin = std::min(begin, end);
|
||||
return true;
|
||||
}
|
||||
|
||||
inline const std::uint64_t kHintHoldMs = 1300; /**< 提示浮层完全显示的停留时长(毫秒)。 */
|
||||
inline const std::uint64_t kHintFadeMs = 500; /**< 提示浮层的淡出时长(毫秒)。 */
|
||||
|
||||
/**
|
||||
* @brief 实验性功能浮动面板的遍历顺序(命中测试与绘制共用,保证层序一致)。
|
||||
*
|
||||
* 顺序即绘制层序:先绘制的在下方,最后绘制的在最上方。
|
||||
*/
|
||||
inline const EdgePanelKind kEdgePanelKinds[] = {EdgePanelKind::COUNT, EdgePanelKind::CENTER,
|
||||
EdgePanelKind::MEAN, EdgePanelKind::PEAK};
|
||||
|
||||
/** 实验性功能浮动面板的种类数(遍历与命中测试统一用它,避免新增功能时漏改)。 */
|
||||
inline const int kEdgePanelKindCount = static_cast<int>(sizeof(kEdgePanelKinds) / sizeof(kEdgePanelKinds[0]));
|
||||
|
||||
/**
|
||||
* @brief 计算一条派生曲线在给定采样窗口内的取值(同步路径与后台线程共用)。
|
||||
*
|
||||
* 只拷贝所需的窗口,再调用与「构建为新信号」完全相同的自由函数,因此
|
||||
* 预览曲线与构建出的采样值逐位一致。本函数**只读**入参、不接触任何成员状态,
|
||||
* 因此可以安全地在后台线程调用。
|
||||
*
|
||||
* @param [IN] signal 目标信号(数据只读)。
|
||||
* @param [IN] windowBegin 窗口起点(采样下标)。
|
||||
* @param [IN] windowEnd 窗口终点(不含)。
|
||||
* @param [IN] kind `DerivedCurveKind` 的整型值。
|
||||
* @param [IN] points 均值点数 X。
|
||||
* @param [IN] iterations 迭代次数 N。
|
||||
* @param [IN] amountPercent 弱峰调整幅度(-400–400)。
|
||||
* @param [IN] cancel 可选的取消标志(透传给算法,使过时任务尽快退出)。
|
||||
* @param [IN] throttle 是否在阶与阶之间让出 CPU(低内存设备的“少量多次”模式)。
|
||||
* @return 曲线数值(长度 = 有效窗口长度)。
|
||||
*/
|
||||
inline std::vector<float> ComputeDerivedWindow(const SignalData& signal, std::size_t windowBegin,
|
||||
std::size_t windowEnd, int kind, int points,
|
||||
int iterations, int amountPercent,
|
||||
const std::atomic<bool>* cancel, bool throttle)
|
||||
{
|
||||
std::vector<float> out;
|
||||
const std::vector<double>& values = signal.values;
|
||||
const std::size_t hi = std::min(windowEnd, values.size());
|
||||
const std::size_t lo = std::min(windowBegin, hi);
|
||||
if (hi <= lo)
|
||||
{
|
||||
return out;
|
||||
}
|
||||
// ⚠️ **整段窗口不要复制**:`VERY_BIG_DATA` 一条曲线要 8 B/采样 × 2580 万 ≈ 206 MB,
|
||||
// 而“预览曲线一律按整条信号计算”正是最常见的情形。
|
||||
// 采样在本函数执行期间不会被修改(结构变化前宿主会先 `PauseDerivedAsync`),
|
||||
// 因此整段时直接引用原向量;只有子窗口才切片复制。
|
||||
const bool wholeSignal = (lo == 0) && (hi == values.size());
|
||||
const std::vector<double> window = wholeSignal
|
||||
? std::vector<double>()
|
||||
: std::vector<double>(
|
||||
values.begin() + static_cast<std::ptrdiff_t>(lo),
|
||||
values.begin() + static_cast<std::ptrdiff_t>(hi));
|
||||
const std::vector<double>& input = wholeSignal ? values : window;
|
||||
if (kind == static_cast<int>(DerivedCurveKind::WEAK_PEAK))
|
||||
{
|
||||
ComputeWeakPeakAdjust(input, static_cast<double>(amountPercent) / 100.0, iterations, out,
|
||||
cancel, throttle);
|
||||
}
|
||||
else
|
||||
{
|
||||
ComputeMovingAverageIterated(input, points, iterations, out, cancel, throttle);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 包络带的单个像素列(已映射到屏幕坐标)。
|
||||
*/
|
||||
struct BandColumn
|
||||
{
|
||||
float x = 0.0f; /**< 列中心横坐标。 */
|
||||
float top = 0.0f; /**< 本列的**上**包络(较小的纵坐标)。 */
|
||||
float bottom = 0.0f; /**< 本列的**下**包络(较大的纵坐标)。 */
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief 把逐列极值拼成闭合的包络带多边形。
|
||||
*
|
||||
* 输出格式为「上包络正向 + 下包络反向」,调用方(`PaintLane`)依赖这个顺序把
|
||||
* 它拆成上下两条折线画成一个闭合环。
|
||||
*
|
||||
* ⚠️ **几何必须是精确的**:这里**不做任何**列窗口加宽。曾经为了让填充更快而做过
|
||||
* ±1 列的极值扩展,结果把"梳状"振荡糊成了实心块,波形细节整体丢失
|
||||
* (用户反馈"显示效果没有之前好了")。性能问题改由**绘制侧**解决:把梳状带
|
||||
* 交给无抗锯齿填充(见 `spiky` 输出与 `PlotView::m_fastFrame`)。
|
||||
*
|
||||
* @param [IN] columns 逐列极值(同一段内按列顺序排列)。
|
||||
* @param [OUT] band 追加写入的包络顶点(上包络正向 + 下包络反向)。
|
||||
* @param [OUT] bandBreaks 追加写入的各段起点(相对 band 的下标)。
|
||||
* @param [OUT] spiky 该段是否为「梳状 / 密集满幅」(建议关闭抗锯齿)。
|
||||
* @param [IN] laneHeight 网格的可见像素高度(用于「覆盖率」判据;≤0 时跳过)。
|
||||
*/
|
||||
inline void EmitEnvelopeBand(const std::vector<BandColumn>& columns, std::vector<gd::PointF>& band,
|
||||
std::vector<std::size_t>& bandBreaks, bool& spiky, float laneHeight)
|
||||
{
|
||||
if (columns.size() < 2)
|
||||
{
|
||||
return;
|
||||
}
|
||||
const std::size_t count = columns.size();
|
||||
bandBreaks.push_back(band.size());
|
||||
|
||||
// 「梳状」判定:相邻列的上下包络几乎不相交(振荡周期与像素列宽相当时出现)。
|
||||
// 这种形状下抗锯齿填充的扫描线代价暴涨,而抗锯齿对它的平滑作用几乎看不出来。
|
||||
std::size_t inverted = 0;
|
||||
const float slack = kEnvelopeMinPixelSpan;
|
||||
for (std::size_t index = 1; index < count; ++index)
|
||||
{
|
||||
if (columns[index].top > columns[index - 1].bottom + slack ||
|
||||
columns[index].bottom < columns[index - 1].top - slack)
|
||||
{
|
||||
++inverted;
|
||||
}
|
||||
}
|
||||
if (inverted * 8 > count)
|
||||
{
|
||||
spiky = true;
|
||||
}
|
||||
|
||||
// 「密集满幅」判定:包络带在**每一列内部**都横跨了网格的大部分高度
|
||||
//(编码器方波在 1~2 采样/像素时,多数像素列里都含一次整幅跳变)。
|
||||
// GDI+ **抗锯齿**的代价与这些近竖直、满幅的边成正比:实测同一 4832 顶点的几何
|
||||
// **抗锯齿 694ms、关抗锯齿 29ms(23 倍)**,而这么密的色块上抗锯齿的平滑作用肉眼不可辨。
|
||||
// ⚠️ 只影响「本段是否关抗锯齿」,几何(逐列 min/max)完全不变。
|
||||
if (laneHeight > 0.0f)
|
||||
{
|
||||
std::size_t tallColumns = 0;
|
||||
double covered = 0.0;
|
||||
for (const BandColumn& column : columns)
|
||||
{
|
||||
const float span = column.bottom - column.top;
|
||||
covered += static_cast<double>(span);
|
||||
if (span > 0.5f * laneHeight)
|
||||
{
|
||||
++tallColumns;
|
||||
}
|
||||
}
|
||||
// 「锯齿」判定(最能代表 GDI+ 抗锯齿代价):相邻列的上下包络**整幅跳变**。
|
||||
std::size_t jumps = 0;
|
||||
const float jumpThreshold = 0.25f * laneHeight;
|
||||
for (std::size_t index = 1; index < count; ++index)
|
||||
{
|
||||
const float dTop = std::fabs(columns[index].top - columns[index - 1].top);
|
||||
const float dBottom = std::fabs(columns[index].bottom - columns[index - 1].bottom);
|
||||
if (dTop > jumpThreshold || dBottom > jumpThreshold)
|
||||
{
|
||||
++jumps;
|
||||
}
|
||||
}
|
||||
if (tallColumns * 4 > count || jumps * 4 > count ||
|
||||
covered > 0.45 * static_cast<double>(count) * static_cast<double>(laneHeight))
|
||||
{
|
||||
spiky = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (std::size_t index = 0; index < count; ++index)
|
||||
{
|
||||
band.push_back(gd::PointF(columns[index].x, columns[index].top));
|
||||
}
|
||||
for (std::size_t index = count; index-- > 0;)
|
||||
{
|
||||
band.push_back(gd::PointF(columns[index].x, columns[index].bottom));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 双光标差值的完整显示文本("Δt:" + 复合单位间隔)。
|
||||
* @param [IN] seconds 时间间隔(秒)。
|
||||
* @return 形如 "Δt:1h 1min 2s" 的文本。
|
||||
*/
|
||||
inline std::wstring FormatSpanText(double seconds)
|
||||
{
|
||||
return L"Δt:" + FormatDurationCompound(seconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 时间光标的读数文本("数值 + s 后缀")。
|
||||
*
|
||||
* 时间轴本身就是秒,但与幅值读数放在一起时没有单位容易误读,
|
||||
* 因此读数恒带 "s" 后缀;内联编辑仍使用不带单位的纯数值(见 BeginEdit 调用处)。
|
||||
*
|
||||
* @param [IN] seconds 光标所在时刻(秒)。
|
||||
* @return 形如 "1.25s" 的文本。
|
||||
*/
|
||||
inline std::wstring FormatCursorTime(double seconds)
|
||||
{
|
||||
return FormatValue(seconds) + L"s";
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 按比例缩放颜色的 alpha 通道(用于提示浮层淡出)。
|
||||
* @param [IN] argb 原始 0xAARRGGBB 颜色。
|
||||
* @param [IN] alpha 新的 alpha(0-255)。
|
||||
* @return 缩放后的颜色。
|
||||
*/
|
||||
inline std::uint32_t ScaleAlpha(std::uint32_t argb, unsigned int alpha)
|
||||
{
|
||||
const unsigned int a = std::min(255u, alpha);
|
||||
return (argb & 0x00FFFFFFu) | (a << 24);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 将数值限制到合法区间。
|
||||
* @param [IN] value 输入值。
|
||||
* @param [IN] low 下限。
|
||||
* @param [IN] high 上限。
|
||||
* @return 限制后的数值。
|
||||
*/
|
||||
inline double ClampDouble(double value, double low, double high)
|
||||
{
|
||||
if (value < low)
|
||||
{
|
||||
return low;
|
||||
}
|
||||
if (value > high)
|
||||
{
|
||||
return high;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 组装图例文本:使用信号当前显示名(用户重命名后立即生效)。
|
||||
* @param [IN] data 信号数据。
|
||||
* @return 图例文本。
|
||||
*/
|
||||
inline std::wstring BuildLegendText(const SignalData& data)
|
||||
{
|
||||
return data.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 将信号样式颜色按不透明度换算为实际绘制颜色。
|
||||
* @param [IN] style 信号样式。
|
||||
* @return 叠加不透明度后的 0xAARRGGBB 颜色值。
|
||||
*/
|
||||
inline std::uint32_t StyleColor(const SignalStyle& style)
|
||||
{
|
||||
double opacity = static_cast<double>(style.opacity);
|
||||
if (opacity < 0.0)
|
||||
{
|
||||
opacity = 0.0;
|
||||
}
|
||||
if (opacity > 1.0)
|
||||
{
|
||||
opacity = 1.0;
|
||||
}
|
||||
return WithAlpha(style.color, static_cast<unsigned int>(opacity * 255.0 + 0.5));
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 由信号色派生一个「高对比度」颜色:保持色相、大幅拉开明度。
|
||||
*
|
||||
* 用途:「边沿计数(中心轴)」的平均值曲线叠加在原始波形之上,
|
||||
* 若直接用信号色会被波形淹没。这里把信号色向黑或白方向大幅偏移,
|
||||
* 并**同时考虑与原始波形色以及与绘图区底色的距离**,取对比更强的一侧,
|
||||
* 保证曲线既能与波形区分、又能压在底图上看得清。
|
||||
*
|
||||
* 结果是从信号色派生的,因此原信号改颜色时曲线颜色会自动跟着变。
|
||||
*
|
||||
* @param [IN] color 原始信号色(0xAARRGGBB)。
|
||||
* @param [IN] background 绘图区底色,用于评估可见度。
|
||||
* @return 派生出的高对比度颜色(alpha 与输入一致,由调用方再覆盖)。
|
||||
*/
|
||||
inline std::uint32_t HighContrastDerived(std::uint32_t color, std::uint32_t background = 0xFFFDF6E3u)
|
||||
{
|
||||
const auto squaredDistance = [](std::uint32_t a, std::uint32_t b)
|
||||
{
|
||||
const double dr =
|
||||
static_cast<double>((a >> 16) & 0xFFu) - static_cast<double>((b >> 16) & 0xFFu);
|
||||
const double dg =
|
||||
static_cast<double>((a >> 8) & 0xFFu) - static_cast<double>((b >> 8) & 0xFFu);
|
||||
const double db = static_cast<double>(a & 0xFFu) - static_cast<double>(b & 0xFFu);
|
||||
return dr * dr + dg * dg + db * db;
|
||||
};
|
||||
|
||||
const std::uint32_t darker = MixColor(color, 0xFF000000u, 0.72);
|
||||
const std::uint32_t lighter = MixColor(color, 0xFFFFFFFFu, 0.72);
|
||||
// 评分:与原始波形色的区分度为主(权重 1.0),与底色的可见度为辅(权重 0.6)。
|
||||
const double scoreDarker =
|
||||
squaredDistance(darker, color) + 0.6 * squaredDistance(darker, background);
|
||||
const double scoreLighter =
|
||||
squaredDistance(lighter, color) + 0.6 * squaredDistance(lighter, background);
|
||||
return (scoreDarker >= scoreLighter) ? darker : lighter;
|
||||
}
|
||||
/** @brief FNV-1a 混合(面板像素缓存键共用)。 */
|
||||
inline void MixPanelKey(std::uint64_t& hash, std::uint64_t value)
|
||||
{
|
||||
hash ^= value;
|
||||
hash *= 1099511628211ull;
|
||||
}
|
||||
} // namespace pvdetail
|
||||
|
||||
using namespace pvdetail;
|
||||
|
||||
} // namespace jdi
|
||||
|
||||
#endif // __PLOT_VIEW_INTERNAL_HPP__
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,316 @@
|
||||
#ifndef __SIGNALPANEL_INTERNAL_HPP__
|
||||
#define __SIGNALPANEL_INTERNAL_HPP__
|
||||
|
||||
// SignalPanel 组件内部共享的常量与工具函数(私有头,仅本组件的 .cpp 使用)。
|
||||
//
|
||||
// 本组件的实现按功能拆成多个协作文件,它们共用这一份内部工具集。
|
||||
//
|
||||
// ⚠️ 为让多个翻译单元都能使用,原匿名命名空间改为**具名**命名空间 `spdetail`,
|
||||
// 并把其中每个函数与常量标为 `inline` —— 单一定义、单一副本,不会产生代码重复。
|
||||
// 各实现文件在包含本头之后用 `using namespace spdetail;` 引入这些工具,
|
||||
// 因此函数体里原有的非限定调用一行都不用改。
|
||||
|
||||
#include <windows.h>
|
||||
#include <objidl.h>
|
||||
#include <gdiplus.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "SignalPanel.hpp"
|
||||
#include "UiControls.hpp"
|
||||
#include "Utf8Util.hpp"
|
||||
|
||||
namespace jdi
|
||||
{
|
||||
|
||||
namespace spdetail
|
||||
{
|
||||
/**
|
||||
* @brief SignalPanel 内部工具集(常量与自由函数)。
|
||||
*
|
||||
* 全部符号都是 `inline` ⇒ 多个翻译单元共用同一个定义:既不重复符号,也不产生多份代码。
|
||||
* 不要把它们移回匿名命名空间(那样每个 .cpp 都会各自复制一份)。
|
||||
*/
|
||||
inline const float kHeaderHeight = 38.0f; /**< 标题区高度(逻辑像素)。 */
|
||||
inline const float kGroupRowHeight = 30.0f; /**< 分组行高。 */
|
||||
inline const float kSignalRowHeight = 27.0f; /**< 信号行高。 */
|
||||
inline const float kPadding = 12.0f; /**< 通用内边距。 */
|
||||
inline const float kCheckSize = 15.0f; /**< 复选框尺寸。 */
|
||||
inline const float kSwatchSize = 12.0f; /**< 信号行颜色小方块尺寸。 */
|
||||
inline const float kBadgeWidth = 30.0f; /**< 网格徽标宽度。 */
|
||||
inline const float kBadgeHeight = 17.0f; /**< 网格徽标高度。 */
|
||||
inline const float kCorner = 4.0f; /**< 通用圆角半径。 */
|
||||
inline const float kDisclosureWidth = 16.0f; /**< 折叠三角占位宽度。 */
|
||||
inline const float kIndent = 14.0f; /**< 信号行相对分组行的缩进。 */
|
||||
inline const float kScrollBarThickness = 8.0f; /**< 滚动条宽度。 */
|
||||
inline const float kCloseButtonSize = 18.0f; /**< 行尾关闭(✕)按钮尺寸(分组行与信号行共用)。 */
|
||||
inline const float kGroupSeparatorGap = 7.0f; /**< 分组标签与分隔线/名称的间距。 */
|
||||
inline const float kGroupSeparatorWidth = 1.0f; /**< 分组标签右侧分隔线宽度。 */
|
||||
inline const float kFilterHeight = 26.0f; /**< 「筛选信号」输入框高度。 */
|
||||
inline const float kFilterPadV = 5.0f; /**< 筛选框上下留白。 */
|
||||
inline const float kFilterTextPad = 4.0f; /**< 筛选框内文字/输入框相对外框的内缩(四边一致)。 */
|
||||
inline const float kRowTailGap = 6.0f; /**< 网格徽标与行尾 ✕ 之间的间距。 */
|
||||
|
||||
/**
|
||||
* @brief 大小写无关的通配符匹配(`*` 匹配任意长度的任意字符序列)。
|
||||
* @param [IN] text 待匹配文本。
|
||||
* @param [IN] pattern 模式(可含多个 `*`)。
|
||||
* @return 匹配返回 true。
|
||||
*/
|
||||
inline bool WildcardMatchNoCase(const std::wstring& text, const std::wstring& pattern)
|
||||
{
|
||||
const auto fold = [](wchar_t character) -> wchar_t
|
||||
{
|
||||
// 仅对基本拉丁字母做大小写折叠(中文等其它字符原样比较)。
|
||||
if (character >= L'A' && character <= L'Z')
|
||||
{
|
||||
return static_cast<wchar_t>(character - L'A' + L'a');
|
||||
}
|
||||
return character;
|
||||
};
|
||||
|
||||
// 经典双指针回溯:`star` 记录最近一个 `*` 的位置,`resume` 记录当时尝试的文本位置。
|
||||
std::size_t t = 0;
|
||||
std::size_t p = 0;
|
||||
std::size_t star = std::wstring::npos;
|
||||
std::size_t resume = 0;
|
||||
|
||||
while (t < text.size())
|
||||
{
|
||||
if (p < pattern.size() && pattern[p] == L'*')
|
||||
{
|
||||
star = p;
|
||||
resume = t;
|
||||
++p;
|
||||
}
|
||||
else if (p < pattern.size() && fold(pattern[p]) == fold(text[t]))
|
||||
{
|
||||
++p;
|
||||
++t;
|
||||
}
|
||||
else if (star != std::wstring::npos)
|
||||
{
|
||||
p = star + 1;
|
||||
++resume;
|
||||
t = resume;
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 文本已耗尽:模式剩余部分必须全是 `*`。
|
||||
while (p < pattern.size() && pattern[p] == L'*')
|
||||
{
|
||||
++p;
|
||||
}
|
||||
return p == pattern.size();
|
||||
}
|
||||
|
||||
// 样式编辑区:固定五行网格(颜色 / 样式 / 调整 / 线宽 / 透明度),
|
||||
// 行高、间距与内容列宽度统一,保证面板宽度变化时各行控件始终对齐,且不会被无限制拉伸。
|
||||
inline const float kStylePadH = 12.0f; /**< 样式区左右内边距。 */
|
||||
inline const float kStylePadV = 12.0f; /**< 样式区上下内边距。 */
|
||||
inline const float kStyleRowHeight = 26.0f; /**< 样式区单行高度。 */
|
||||
inline const float kStyleRowGap = 10.0f; /**< 样式区行间距。 */
|
||||
inline const float kStyleLabelWidth = 44.0f; /**< 样式行标签列宽度。 */
|
||||
inline const float kStyleLabelGap = 8.0f; /**< 标签列与内容列间距。 */
|
||||
inline const float kStyleRowCount = 5.0f; /**< 样式区固定行数(不含顶部「目标」行)。 */
|
||||
inline const float kStyleTargetRowHeight = 26.0f; /**< 样式区顶部「目标」行高度(与其它行一致)。 */
|
||||
/**
|
||||
* @brief GX 网格标签与信号名之间的间距(逻辑像素)。
|
||||
*
|
||||
* 用户要求:目标信号已绘制到网格时,在信号名**后方**显示它所在的 GX 标签。
|
||||
*/
|
||||
inline const float kTargetBadgeGap = 8.0f;
|
||||
/**
|
||||
* @brief 平滑滚动的动画时长(毫秒)。
|
||||
*
|
||||
* 距离越远时长越长(但不会无限增长),靠近时由 ease-out 自动变慢 —— 用户要求
|
||||
* “越远时越快、靠近时变慢”且“整个动画过程不要太久”。
|
||||
*/
|
||||
inline const float kScrollAnimMinMs = 130.0f; /**< 最短时长(短距离)。 */
|
||||
inline const float kScrollAnimMaxMs = 300.0f; /**< 最长时长(长距离)。 */
|
||||
inline const float kScrollAnimMsPerPixel = 0.35f; /**< 每像素距离增加的时长。 */
|
||||
|
||||
// ---- 分组折起/展开动画(用户 2026-10-16 要求)----
|
||||
// 整组信号作为一个**刚性整体**向上平移(收起)/ 向下平移(展开),
|
||||
// 越过组名下边界的部分被裁掉 —— 即“抽拉卷尺”观感:元素相对位置不变。
|
||||
// 总时长 = clamp(基础 + 行数×每行, 下限, 上限),保证“整个动画过程不会太久”。
|
||||
inline const float kGroupAnimBaseMs = 115.0f; /**< 动画基础时长。 */
|
||||
inline const float kGroupAnimPerRowMs = 6.0f; /**< 每多一行增加的时长。 */
|
||||
inline const float kGroupAnimMinMs = 150.0f; /**< 最短时长。 */
|
||||
inline const float kGroupAnimMaxMs = 320.0f; /**< 最长时长。 */
|
||||
inline const float kStyleContentMax = 260.0f; /**< 内容列最大宽度(再拉宽面板也不再延伸)。 */
|
||||
inline const float kSwatchEdge = 18.0f; /**< 颜色色块边长上限(含自定义颜色入口)。 */
|
||||
inline const float kSwatchMin = 11.0f; /**< 颜色色块边长下限。 */
|
||||
inline const float kSwatchGap = 4.0f; /**< 颜色色块最小间距。 */
|
||||
inline const float kSwatchMaxGap = 10.0f; /**< 颜色色块最大间距(用于铺满内容列)。 */
|
||||
inline const float kSliderValueWidth = 52.0f; /**< 滑块行数值文本宽度(透明度与线宽一致)。 */
|
||||
inline const float kSliderValueGap = 8.0f; /**< 滑块轨道与数值文本间距。 */
|
||||
inline const float kTrackHeight = 6.0f; /**< 滑块轨道高度。 */
|
||||
inline const float kAdjustGap = 6.0f; /**< 「调整」行三个按钮之间的间距。 */
|
||||
inline const float kAdjustMenuGap = 4.0f; /**< 「系数/偏移」「镜像」二级面板与按钮左上角的额外间距。 */
|
||||
|
||||
/** 「调整」行的按钮下标(与 `m_adjustRects` 同序)。 */
|
||||
inline const int kAdjustGain = 0; /**< 系数(点击展开系数二级面板)。 */
|
||||
inline const int kAdjustMirror = 1; /**< 镜像(点击展开镜像二级面板)。 */
|
||||
inline const int kAdjustAbsolute = 2; /**< 绝对值。 */
|
||||
inline const int kAdjustCount = 3; /**< 按钮数量。 */
|
||||
|
||||
// 线宽范围与「稀疏信号默认线宽」共用 `DataModel.hpp` 的常量,避免两处漂移
|
||||
//(稀疏信号初始化时用的最大线宽必须与这里滑块的上限完全一致)。
|
||||
inline const float kWidthMin = kSignalLineWidthMin; /**< 允许的最小线宽(px)。 */
|
||||
inline const float kWidthMax = kSignalLineWidthMax; /**< 允许的最大线宽(px)。 */
|
||||
|
||||
/** @brief 系数调整允许的绝对值上限(防止输入极端值把波形完全挤出可视区)。 */
|
||||
inline const double kGainLimit = 10000.0;
|
||||
|
||||
/** @brief 系数为默认值 1(不做任何缩放)时按钮显示为未启用样式。 */
|
||||
inline bool GainIsDefault(float gain)
|
||||
{
|
||||
return std::fabs(gain - 1.0f) < 1.0e-6f;
|
||||
}
|
||||
|
||||
/** @brief 可选绘制样式顺序(不含阶梯方式)。 */
|
||||
inline const DrawMode kModeOptions[] = {DrawMode::LINE, DrawMode::SCATTER, DrawMode::LINE_SCATTER};
|
||||
inline const int kModeOptionCount = static_cast<int>(sizeof(kModeOptions) / sizeof(kModeOptions[0]));
|
||||
|
||||
/**
|
||||
* @brief 计算滑块的便捷命中矩形(比轨道略大,便于拖拽)。
|
||||
* @param [IN] track 轨道矩形。
|
||||
* @param [IN] scale 缩放比例。
|
||||
* @return 命中矩形。
|
||||
*/
|
||||
inline gd::RectF SliderHitRect(const gd::RectF& track, float scale)
|
||||
{
|
||||
return gd::RectF(track.X - 6.0f * scale, track.Y - 9.0f * scale, track.Width + 12.0f * scale,
|
||||
track.Height + 18.0f * scale);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 判断颜色是否偏暗(用于在色块之上选择可读的图标颜色)。
|
||||
* @param [IN] color 0xAARRGGBB 颜色值。
|
||||
* @return 相对亮度低于阈值返回 true。
|
||||
*/
|
||||
inline bool IsDarkColor(std::uint32_t color)
|
||||
{
|
||||
const double r = static_cast<double>((color >> 16) & 0xFFu);
|
||||
const double g = static_cast<double>((color >> 8) & 0xFFu);
|
||||
const double b = static_cast<double>(color & 0xFFu);
|
||||
return (0.299 * r + 0.587 * g + 0.114 * b) < 140.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 将数值限制到合法区间。
|
||||
* @param [IN] value 输入值。
|
||||
* @param [IN] low 下限。
|
||||
* @param [IN] high 上限。
|
||||
* @return 限制后的数值。
|
||||
*/
|
||||
inline float ClampFloat(float value, float low, float high)
|
||||
{
|
||||
return (value < low) ? low : ((value > high) ? high : value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 数值文本:**至少两位小数**,精度更高时按实际精度显示。
|
||||
*
|
||||
* 例:`1` → `1.00`、`2.5` → `2.50`、`0.125` → `0.125`、`1.2345678` → `1.2345678`。
|
||||
* 实现:从 2 位小数开始递增位数,直到文本能精确表示该数值
|
||||
*(容差 1e-7 相对量,与 float 精度匹配)。
|
||||
*
|
||||
* @param [IN] value 数值。
|
||||
* @return 显示文本。
|
||||
*/
|
||||
inline std::wstring FormatNumberText(double value)
|
||||
{
|
||||
const double tolerance = std::max(1.0e-12, std::fabs(value) * 1.0e-7);
|
||||
for (int decimals = 2; decimals <= 9; ++decimals)
|
||||
{
|
||||
const std::wstring text = jdi::FormatNumber(value, decimals);
|
||||
const double parsed = std::wcstod(text.c_str(), nullptr);
|
||||
if (std::fabs(parsed - value) <= tolerance)
|
||||
{
|
||||
return text;
|
||||
}
|
||||
}
|
||||
return jdi::FormatNumber(value, 9);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 带正负符号的数值文本(非编辑态显示偏移量时使用)。
|
||||
*
|
||||
* 规则(用户明确要求):**正数带 `+`、负数带 `-`、0 不带任何符号**。
|
||||
* (旧实现 0 也会显示 `+0.00`,用户报告“偏移调整的 Y 轴输入框默认值为 0 时也显示了正号,这不正确”。)
|
||||
* @param [IN] value 数值。
|
||||
* @return 形如 `+1.50` / `-0.25` / `0.00` 的文本。
|
||||
*/
|
||||
inline std::wstring FormatSignedNumber(double value)
|
||||
{
|
||||
const std::wstring text = FormatNumberText(std::fabs(value));
|
||||
if (value > 0.0)
|
||||
{
|
||||
return L"+" + text;
|
||||
}
|
||||
if (value < 0.0)
|
||||
{
|
||||
return L"-" + text;
|
||||
}
|
||||
return text; // 0:不带符号
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 把系数格式化为界面文本(与偏移量同一套两位小数规则 + 显式正号)。
|
||||
*
|
||||
* 规则(用户明确要求):“系数调整”输入框**非编辑态**显示时,非 0 正值前面加 `+`,
|
||||
* 负值按原样显示负号,0 不带符号。编辑态仍用不带符号的纯数值(见 `FormatNumberText`)。
|
||||
* @param [IN] gain 系数。
|
||||
* @return 显示文本。
|
||||
*/
|
||||
inline std::wstring FormatGainText(float gain)
|
||||
{
|
||||
const double value = static_cast<double>(gain);
|
||||
if (value > 0.0)
|
||||
{
|
||||
return L"+" + FormatNumberText(value);
|
||||
}
|
||||
return FormatNumberText(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 「时间轴」偏移的非编辑态显示文本(`Δt:` + 带正负号的复合单位间隔)。
|
||||
*
|
||||
* 规则(用户明确要求):在 `Δt:` 冒号后的时间显示值**前面加上正负号**,
|
||||
* 只有值为 0 时不显示符号。示例:`Δt:+1h 1min 2s` / `Δt:-5s 60ms` / `Δt:0s`。
|
||||
*(`FormatDurationCompound` 内部取绝对值,因此符号由这里补上。)
|
||||
* @param [IN] seconds 时间偏移(秒,可为负)。
|
||||
* @return 显示文本。
|
||||
*/
|
||||
inline std::wstring FormatOffsetTimeText(double seconds)
|
||||
{
|
||||
if (seconds > 0.0)
|
||||
{
|
||||
return L"Δt:+" + FormatDurationCompound(seconds);
|
||||
}
|
||||
if (seconds < 0.0)
|
||||
{
|
||||
return L"Δt:-" + FormatDurationCompound(seconds);
|
||||
}
|
||||
return L"Δt:" + FormatDurationCompound(0.0);
|
||||
}
|
||||
} // namespace spdetail
|
||||
|
||||
using namespace spdetail;
|
||||
|
||||
} // namespace jdi
|
||||
|
||||
#endif // __SIGNALPANEL_INTERNAL_HPP__
|
||||
|
||||
@@ -0,0 +1,697 @@
|
||||
// SignalPanel 实现分片 —— 本文件只包含 SignalPanel 的**成员函数定义**(纯代码搬移,行为与拆分前完全一致)。
|
||||
//
|
||||
// 共用的常量与自由函数在 SignalPanelInternal.hpp(命名空间 `spdetail`,全部 inline)。
|
||||
// 分片按“职责”划分:核心(状态/布局/几何)、渲染、交互与编辑,见各文件名。
|
||||
#include "SignalPanelInternal.hpp"
|
||||
|
||||
namespace jdi
|
||||
{
|
||||
|
||||
void SignalPanel::PaintStickyGroupRow(gd::Graphics& g, int row)
|
||||
{
|
||||
if (row < 0 || row >= static_cast<int>(m_rows.size()) ||
|
||||
m_rows[static_cast<std::size_t>(row)].kind != RowKind::GROUP)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const int rowCount = static_cast<int>(m_rows.size());
|
||||
const float height = m_rows[static_cast<std::size_t>(row)].height;
|
||||
const float naturalY = RowRect(row).Y;
|
||||
const float stickyY = StickyGroupRowTop(row, rowCount);
|
||||
if (stickyY + height <= m_listRect.Y + 0.5f)
|
||||
{
|
||||
return; // 已经整块滑出列表上边界
|
||||
}
|
||||
|
||||
m_rowYOverrideActive = true;
|
||||
m_rowYOverrideY = stickyY;
|
||||
// 悬浮行必须是不透明的,否则下面的信号行会与组名叠在一起看不清;
|
||||
// `FillRowRect` 会把矩形夹到列表矩形(GDI 填充不受 GDI+ 裁剪约束)。
|
||||
SetRowClip(gd::RectF(m_listRect.X, stickyY, m_listRect.Width, height));
|
||||
FillRowRect(g, gd::RectF(m_listRect.X, stickyY, m_listRect.Width, height), m_theme->panelBg);
|
||||
PaintGroupRow(g, row);
|
||||
ClearRowClip();
|
||||
// 只有真正“浮”在其它行之上时才补一条底部边界线(否则会与正常绘制有差异)。
|
||||
if (stickyY > naturalY + 0.5f)
|
||||
{
|
||||
UiDrawLine(g, m_listRect.X, stickyY + height - 0.5f, m_listRect.X + m_listRect.Width,
|
||||
stickyY + height - 0.5f, m_theme->border, 1.0f);
|
||||
}
|
||||
m_rowYOverrideActive = false;
|
||||
}
|
||||
|
||||
void SignalPanel::Paint(gd::Graphics& g)
|
||||
{
|
||||
if (m_theme == nullptr || m_fonts == nullptr)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EnsureRows();
|
||||
UiFillRect(g, m_area, m_theme->panelBg);
|
||||
|
||||
// 标题区。
|
||||
{
|
||||
std::wstring title = L"信号";
|
||||
if (m_dataSet != nullptr && m_tab != nullptr)
|
||||
{
|
||||
title += L" · " + std::to_wstring(m_tab->VisibleCount()) + L"/" +
|
||||
std::to_wstring(TotalSignalCount()) + L" 已选";
|
||||
}
|
||||
|
||||
gd::RectF titleRect(m_headerRect.X + kPadding * m_scale, m_headerRect.Y,
|
||||
std::max(20.0f, m_headerRect.Width - kPadding * 2.0f * m_scale -
|
||||
m_toggleAllRect.Width - 8.0f * m_scale),
|
||||
m_headerRect.Height);
|
||||
UiText(g, UiEllipsize(title, m_fonts->uiBold, titleRect.Width), m_fonts->uiBold, titleRect,
|
||||
m_theme->textStrong, TextAlign::LEFT);
|
||||
|
||||
const bool allVisible = AreAllSignalsVisible();
|
||||
const std::wstring toggleLabel = allVisible ? L"全部隐藏" : L"全部显示";
|
||||
const bool toggleHover = (m_hoverStyle == StyleHit::TOGGLE_ALL);
|
||||
if (toggleHover)
|
||||
{
|
||||
UiFillRoundRect(g, m_toggleAllRect, kCorner * m_scale, m_theme->hover);
|
||||
}
|
||||
UiDrawRoundRect(g, m_toggleAllRect, kCorner * m_scale, m_theme->border, 1.0f);
|
||||
const bool enabled = (m_dataSet != nullptr && !m_dataSet->Empty());
|
||||
UiText(g, toggleLabel, m_fonts->uiSmall, m_toggleAllRect,
|
||||
enabled ? m_theme->text : m_theme->textMuted, TextAlign::CENTER);
|
||||
}
|
||||
|
||||
// 「筛选信号」输入框:未输入内容时显示占位提示,单击进入编辑,输入实时过滤下方列表。
|
||||
{
|
||||
UiFillRoundRect(g, m_filterRect, kCorner * m_scale, m_theme->cardBg);
|
||||
UiDrawRoundRect(g, m_filterRect, kCorner * m_scale,
|
||||
m_filterEditing ? m_theme->accent : m_theme->border,
|
||||
m_filterEditing ? 1.6f : 1.0f);
|
||||
|
||||
const float pad = kFilterTextPad * m_scale;
|
||||
const gd::RectF clearRect = FilterClearRect();
|
||||
// 有输入内容时在右侧留出 ✕ 的位置,文本不会压到按钮上。
|
||||
const float textRight = (clearRect.Width > 0.0f) ? clearRect.X : (m_filterRect.X + m_filterRect.Width);
|
||||
const float textWidth = std::max(8.0f, textRight - pad - (m_filterRect.X + pad));
|
||||
const gd::RectF textRect(m_filterRect.X + pad, m_filterRect.Y, textWidth,
|
||||
m_filterRect.Height);
|
||||
|
||||
if (m_filterEditing)
|
||||
{
|
||||
// 外框(m_filterRect)已经画好了底板与强调色边框,这里只叠加文本内容。
|
||||
// 若改用 UiDrawEditField,会在外框内部再套一个带边框的圆角框,
|
||||
// 空内容时只剩一圈内框十分突兀(用户明确要求去掉这层"内圈")。
|
||||
gd::RectF fieldRect = FilterFieldRect();
|
||||
fieldRect.Width = std::max(8.0f, textWidth + pad);
|
||||
UiDrawEditFieldContent(g, fieldRect, m_filterEdit, m_fonts->uiSmall, m_scale);
|
||||
}
|
||||
else if (m_filterText.empty())
|
||||
{
|
||||
UiText(g, L"筛选信号", m_fonts->uiSmall, textRect, m_theme->textMuted, TextAlign::LEFT);
|
||||
}
|
||||
else
|
||||
{
|
||||
UiText(g, UiEllipsize(m_filterText, m_fonts->uiSmall, textWidth), m_fonts->uiSmall,
|
||||
textRect, m_theme->text, TextAlign::LEFT);
|
||||
}
|
||||
|
||||
// 清空按钮 ✕:仅在筛选框有内容时出现,悬停高亮(鼠标光标保持箭头)。
|
||||
if (clearRect.Width > 0.0f)
|
||||
{
|
||||
const bool clearHot = (m_hoverStyle == StyleHit::FILTER_CLEAR);
|
||||
if (clearHot)
|
||||
{
|
||||
UiFillRoundRect(g, clearRect, 3.0f * m_scale, WithAlpha(m_theme->danger, 0x33));
|
||||
}
|
||||
const std::uint32_t crossColor = clearHot ? m_theme->danger : m_theme->textMuted;
|
||||
const float crossPad = clearRect.Width * 0.28f;
|
||||
UiDrawLine(g, clearRect.X + crossPad, clearRect.Y + crossPad,
|
||||
clearRect.X + clearRect.Width - crossPad,
|
||||
clearRect.Y + clearRect.Height - crossPad, crossColor, 1.5f * m_scale);
|
||||
UiDrawLine(g, clearRect.X + clearRect.Width - crossPad, clearRect.Y + crossPad,
|
||||
clearRect.X + crossPad, clearRect.Y + clearRect.Height - crossPad, crossColor,
|
||||
1.5f * m_scale);
|
||||
}
|
||||
}
|
||||
|
||||
// 列表区。
|
||||
{
|
||||
const int saveState = g.Save();
|
||||
g.SetClip(gd::RectF(m_listRect.X, m_listRect.Y, m_listRect.Width, m_listRect.Height));
|
||||
|
||||
if (m_dataSet == nullptr || m_dataSet->Empty())
|
||||
{
|
||||
UiText(g, L"尚未加载数据,请打开数据文件", m_fonts->uiSmall, m_listRect, m_theme->textMuted,
|
||||
TextAlign::CENTER);
|
||||
}
|
||||
else if (m_rows.empty() && !m_filterText.empty())
|
||||
{
|
||||
// 筛选结果为空:明确提示"无匹配",而不是让人误以为数据丢失。
|
||||
UiText(g, L"无匹配的筛选信号", m_fonts->uiSmall, m_listRect, m_theme->textMuted,
|
||||
TextAlign::CENTER);
|
||||
}
|
||||
else
|
||||
{
|
||||
const int rowCount = static_cast<int>(m_rows.size());
|
||||
// 先定出要「悬浮」的分组行(若有):常规循环跳过它,最后统一钉在列表顶部绘制,
|
||||
// 否则它会先被画一次在滚动位置上、又被画一次在顶部(视觉上重叠两份)。
|
||||
const int stickyRow = StickyGroupRowIndex(rowCount);
|
||||
for (int row = 0; row < rowCount; ++row)
|
||||
{
|
||||
if (row == stickyRow)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
const gd::RectF rect = RowRect(row);
|
||||
if (rect.Y + rect.Height < m_listRect.Y || rect.Y > m_listRect.Y + m_listRect.Height)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 折起/展开动画:信号行整组向上平移,越过组名下边界的部分要“消失(藏进组名栏)”。
|
||||
// 已经完全越过的行整行跳过(省一次绘制),部分越过的行把裁剪上边界抬到组名下边界;
|
||||
// ⚠️ 此时还必须把 **GDI+ 裁剪**也收紧到同一条线 —— 行内的色块 / 复选框 / 网格徽标
|
||||
// 走的是 GDI+ 绘制(只受 `SetClip` 约束),不收紧就会画到组名栏上面去。
|
||||
const int rowSave = g.Save();
|
||||
SetRowClip(rect);
|
||||
const float hideTop = AnimationHideTopForRow(row, stickyRow);
|
||||
if (hideTop > rect.Y + 0.5f)
|
||||
{
|
||||
if (rect.Y + rect.Height <= hideTop + 0.5f)
|
||||
{
|
||||
g.Restore(rowSave);
|
||||
ClearRowClip();
|
||||
continue;
|
||||
}
|
||||
ClampRowClipTop(hideTop);
|
||||
g.SetClip(m_rowClipRect, gd::CombineModeIntersect);
|
||||
}
|
||||
|
||||
if (m_rows[static_cast<std::size_t>(row)].kind == RowKind::GROUP)
|
||||
{
|
||||
PaintGroupRow(g, row);
|
||||
}
|
||||
else
|
||||
{
|
||||
PaintSignalRow(g, row);
|
||||
}
|
||||
g.Restore(rowSave);
|
||||
ClearRowClip();
|
||||
}
|
||||
|
||||
PaintStickyGroupRow(g, stickyRow);
|
||||
}
|
||||
|
||||
g.Restore(saveState);
|
||||
}
|
||||
|
||||
PaintScrollBar(g);
|
||||
PaintStyleSection(g);
|
||||
|
||||
// 分隔线。
|
||||
UiDrawLine(g, m_area.X + m_area.Width - 1.0f, m_area.Y, m_area.X + m_area.Width - 1.0f,
|
||||
m_area.Y + m_area.Height, m_theme->border, 1.0f);
|
||||
UiDrawLine(g, m_headerRect.X, m_headerRect.Y + m_headerRect.Height - 1.0f,
|
||||
m_headerRect.X + m_headerRect.Width, m_headerRect.Y + m_headerRect.Height - 1.0f,
|
||||
m_theme->border, 1.0f);
|
||||
if (m_styleRect.Height > 0.0f)
|
||||
{
|
||||
UiDrawLine(g, m_styleRect.X, m_styleRect.Y, m_styleRect.X + m_styleRect.Width, m_styleRect.Y,
|
||||
m_theme->border, 1.0f);
|
||||
}
|
||||
}
|
||||
|
||||
void SignalPanel::PaintRowText(gd::Graphics& g, const std::wstring& text, HFONT font,
|
||||
const gd::RectF& rect, std::uint32_t argb, TextAlign align)
|
||||
{
|
||||
// 行内文本必须显式裁剪:GDI 文本不遵循 GDI+ 的 `SetClip`,
|
||||
// 否则列表滚动时顶部那些“部分可见”的行会把组名/信号名画到筛选框上。
|
||||
// 另外,分组折起动画期间行高会连续缩小 —— 此时必须裁到**本行矩形**内,
|
||||
// 否则垂直居中的文字会溢出到相邻行上。
|
||||
UiTextClipped(g, text, font, rect, argb, align, m_rowClipActive ? m_rowClipRect : m_listRect);
|
||||
}
|
||||
|
||||
void SignalPanel::FillRowRect(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb)
|
||||
{
|
||||
// 夹取而不是裁剪:纯色填充下两者等价,而夹取对 `UiFillRect` 的 GDI 快路径同样有效
|
||||
//(大面积不透明矩形会改走 GDI `FillRect`,它同样不受 GDI+ 裁剪约束)。
|
||||
gd::RectF clipped = rect;
|
||||
clipped.Intersect(m_rowClipActive ? m_rowClipRect : m_listRect);
|
||||
if (clipped.Width <= 0.0f || clipped.Height <= 0.0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
UiFillRect(g, clipped, argb);
|
||||
}
|
||||
|
||||
void SignalPanel::PaintGroupRow(gd::Graphics& g, int row)
|
||||
{
|
||||
if (m_dataSet == nullptr || m_tab == nullptr || row < 0 || row >= static_cast<int>(m_rows.size()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
|
||||
const SignalGroup* group = m_dataSet->GroupAt(item.groupIndex);
|
||||
if (group == nullptr)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const gd::RectF rect = RowRect(row);
|
||||
const bool hovered = (m_hoverRow == row);
|
||||
if (hovered)
|
||||
{
|
||||
FillRowRect(g, rect, m_theme->hover);
|
||||
}
|
||||
FillRowRect(g, gd::RectF(rect.X, rect.Y, 2.0f * m_scale, rect.Height),
|
||||
WithAlpha(m_theme->borderStrong, 0x90));
|
||||
|
||||
const float baseX = rect.X + kPadding * m_scale;
|
||||
const float centerY = rect.Y + rect.Height * 0.5f;
|
||||
const bool collapsed = IsGroupCollapsed(item.groupIndex);
|
||||
|
||||
// 折叠三角。
|
||||
{
|
||||
const float cx = baseX + kDisclosureWidth * m_scale * 0.5f;
|
||||
const std::uint32_t color =
|
||||
(hovered && m_hoverPart == RowPart::DISCLOSURE) ? m_theme->accent : m_theme->text;
|
||||
UiDrawTriangleIcon(g, gd::RectF(cx - 6.0f * m_scale, centerY - 6.0f * m_scale, 12.0f * m_scale,
|
||||
12.0f * m_scale),
|
||||
color, collapsed ? IconDirection::RIGHT : IconDirection::DOWN);
|
||||
}
|
||||
|
||||
// 分组复选框(三态:全选 / 部分 / 未选)。
|
||||
const gd::RectF checkRect(baseX + kDisclosureWidth * m_scale, centerY - kCheckSize * m_scale * 0.5f,
|
||||
kCheckSize * m_scale, kCheckSize * m_scale);
|
||||
const bool allVisible = m_tab->GroupAllVisible(item.groupIndex);
|
||||
const bool anyVisible = m_tab->GroupHasVisible(item.groupIndex);
|
||||
UiDrawCheckBox(g, checkRect, allVisible ? 1 : (anyVisible ? 2 : 0), 3.0f * m_scale);
|
||||
|
||||
const gd::RectF closeRect = GroupCloseRect(row);
|
||||
|
||||
// 分组标题:左侧为“组X”标签,右侧以绘制的竖线分隔分组名(不使用字符),再显示可重命名的名称。
|
||||
const std::wstring groupLabel = GroupLabelText(item.groupIndex);
|
||||
// 项数文本与 `RowNameRect`(组名编辑框)共用同一来源,保证名称宽度与绘制不漂移。
|
||||
const std::wstring stats = GroupStatsText(row);
|
||||
const float groupWidth = GroupLabelWidth(row);
|
||||
const float statsWidth = UiMeasureTextWidth(m_fonts->uiSmall, stats);
|
||||
const float statsRight = closeRect.X - 6.0f * m_scale;
|
||||
const float nameLeft = checkRect.X + checkRect.Width + 8.0f * m_scale;
|
||||
const float nameAreaLeft =
|
||||
nameLeft + groupWidth + kGroupSeparatorGap * 2.0f * m_scale + kGroupSeparatorWidth * m_scale;
|
||||
const float nameWidth =
|
||||
std::max(20.0f, statsRight - statsWidth - 6.0f * m_scale - nameAreaLeft);
|
||||
|
||||
// 序号以强调色突出,便于在多组之间快速定位。
|
||||
PaintRowText(g, groupLabel, m_fonts->uiBold, gd::RectF(nameLeft, rect.Y, groupWidth, rect.Height),
|
||||
m_theme->accent, TextAlign::LEFT);
|
||||
|
||||
// 竖线分隔符:以绘制方式实现,与文字基线无关,排版更稳。
|
||||
FillRowRect(g, gd::RectF(nameLeft + groupWidth + kGroupSeparatorGap * m_scale,
|
||||
rect.Y + 7.0f * m_scale, kGroupSeparatorWidth * m_scale,
|
||||
rect.Height - 14.0f * m_scale),
|
||||
WithAlpha(m_theme->borderStrong, 0xB0));
|
||||
|
||||
if (m_editField == StyleEditField::GROUP_TITLE && m_editRow == row)
|
||||
{
|
||||
UiDrawEditField(g, RowNameRect(row), m_edit, m_fonts->uiSmall, m_scale);
|
||||
}
|
||||
else
|
||||
{
|
||||
PaintRowText(g, UiEllipsize(group->name, m_fonts->uiBold, nameWidth), m_fonts->uiBold,
|
||||
gd::RectF(nameAreaLeft, rect.Y, nameWidth, rect.Height), m_theme->textStrong,
|
||||
TextAlign::LEFT);
|
||||
}
|
||||
|
||||
PaintRowText(g, stats, m_fonts->uiSmall, gd::RectF(statsRight - statsWidth, rect.Y, statsWidth, rect.Height),
|
||||
m_theme->textMuted, TextAlign::RIGHT);
|
||||
|
||||
// 关闭按钮(从工作区移除该分组)。
|
||||
const bool closeHot = hovered && (m_hoverPart == RowPart::CLOSE);
|
||||
if (closeHot)
|
||||
{
|
||||
UiFillRoundRect(g, closeRect, 3.0f * m_scale, m_theme->pressed);
|
||||
}
|
||||
UiDrawCrossIcon(g, closeRect, closeHot ? m_theme->danger : m_theme->textMuted, 1.5f * m_scale);
|
||||
}
|
||||
|
||||
void SignalPanel::PaintSignalRow(gd::Graphics& g, int row)
|
||||
{
|
||||
if (m_dataSet == nullptr || m_tab == nullptr || row < 0 || row >= static_cast<int>(m_rows.size()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
|
||||
const SignalRef ref{item.groupIndex, item.signalIndex};
|
||||
const SignalData* signal = m_dataSet->Resolve(ref);
|
||||
const SignalStyle* style = StyleOf(ref);
|
||||
if (signal == nullptr || style == nullptr)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const gd::RectF rect = RowRect(row);
|
||||
const bool selected = (SelectedRef() == ref);
|
||||
const bool hovered = (m_hoverRow == row);
|
||||
|
||||
if (selected)
|
||||
{
|
||||
FillRowRect(g, rect, m_theme->selection);
|
||||
FillRowRect(g, gd::RectF(rect.X, rect.Y, 2.0f * m_scale, rect.Height), m_theme->accent);
|
||||
}
|
||||
else if (hovered)
|
||||
{
|
||||
FillRowRect(g, rect, m_theme->hover);
|
||||
}
|
||||
|
||||
const float baseX = rect.X + kPadding * m_scale + kIndent * m_scale;
|
||||
const float centerY = rect.Y + rect.Height * 0.5f;
|
||||
|
||||
// 复选框。
|
||||
const gd::RectF checkRect(baseX + kDisclosureWidth * m_scale, centerY - kCheckSize * m_scale * 0.5f,
|
||||
kCheckSize * m_scale, kCheckSize * m_scale);
|
||||
// 可见性属于当前标签页,与全局共享的显示外观分开读取。
|
||||
const bool visible = m_tab->IsVisible(ref);
|
||||
UiDrawCheckBox(g, checkRect, visible ? 1 : 0, 3.0f * m_scale);
|
||||
|
||||
// 颜色块:叠加不透明度,透明度过低时自动补描边(与样式区色块规则一致)。
|
||||
const gd::RectF swatchRect(checkRect.X + checkRect.Width + 6.0f * m_scale,
|
||||
centerY - kSwatchSize * m_scale * 0.5f, kSwatchSize * m_scale,
|
||||
kSwatchSize * m_scale);
|
||||
UiDrawSwatch(g, swatchRect, style->color, visible ? style->opacity : 0.3f, 2.5f * m_scale,
|
||||
m_scale);
|
||||
|
||||
// 网格徽标(可点击,用于调整所属网格)。行尾 ✕ 恒在最右侧,徽标整体前移让位。
|
||||
const int laneIndex = m_tab->LaneOfSignal(ref);
|
||||
const bool badgeVisible = (laneIndex >= 0) && visible;
|
||||
const gd::RectF badgeRect = SignalBadgeRect(row);
|
||||
const gd::RectF removeRect = SignalRemoveRect(row);
|
||||
|
||||
float nameRightLimit = removeRect.X - 6.0f * m_scale;
|
||||
if (badgeVisible)
|
||||
{
|
||||
nameRightLimit = badgeRect.X - 6.0f * m_scale;
|
||||
}
|
||||
|
||||
// 名称:完全保留文件内的列名(含 "[n]" 序号与括号描述),双击可重命名。
|
||||
const float nameLeft = swatchRect.X + swatchRect.Width + 6.0f * m_scale;
|
||||
if (m_editField == StyleEditField::SIGNAL_NAME && m_editRow == row)
|
||||
{
|
||||
UiDrawEditField(g, RowNameRect(row), m_edit, m_fonts->ui, m_scale);
|
||||
}
|
||||
else
|
||||
{
|
||||
const float nameWidth = std::max(16.0f, nameRightLimit - nameLeft);
|
||||
const std::uint32_t nameColor = visible ? m_theme->text : m_theme->textMuted;
|
||||
PaintRowText(g, UiEllipsize(signal->name, m_fonts->ui, nameWidth), m_fonts->ui,
|
||||
gd::RectF(nameLeft, rect.Y, nameWidth, rect.Height), nameColor, TextAlign::LEFT);
|
||||
}
|
||||
|
||||
if (badgeVisible)
|
||||
{
|
||||
const bool badgeHot = hovered && (m_hoverPart == RowPart::LANE_BADGE);
|
||||
UiFillRoundRect(g, badgeRect, kBadgeHeight * m_scale * 0.5f,
|
||||
badgeHot ? m_theme->accentSoft : m_theme->cardBg);
|
||||
UiDrawRoundRect(g, badgeRect, kBadgeHeight * m_scale * 0.5f,
|
||||
badgeHot ? m_theme->accent : m_theme->border, 1.0f);
|
||||
PaintRowText(g, L"G" + std::to_wstring(laneIndex + 1), m_fonts->monoSmall, badgeRect,
|
||||
badgeHot ? m_theme->textStrong : m_theme->text, TextAlign::CENTER);
|
||||
}
|
||||
|
||||
// 「移除该信号」✕:位于信号条目最右端,尺寸/样式/右边距与分组行的关闭 ✕ 完全一致。
|
||||
// 最后绘制,确保它永远压在最上层(与分组行 ✕ 的绘制顺序保持一致)。
|
||||
{
|
||||
const bool removeHot = hovered && (m_hoverPart == RowPart::REMOVE_SIGNAL);
|
||||
if (removeHot)
|
||||
{
|
||||
UiFillRoundRect(g, removeRect, 3.0f * m_scale, m_theme->pressed);
|
||||
}
|
||||
UiDrawCrossIcon(g, removeRect, removeHot ? m_theme->danger : m_theme->textMuted,
|
||||
1.5f * m_scale);
|
||||
}
|
||||
}
|
||||
|
||||
void SignalPanel::PaintScrollBar(gd::Graphics& g)
|
||||
{
|
||||
if (!m_scrollBarVisible || m_contentHeight <= m_listRect.Height)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
UiFillRoundRect(g, m_scrollBarRect, kScrollBarThickness * m_scale * 0.5f, m_theme->gridWeak);
|
||||
UiFillRoundRect(g, ScrollThumbRect(), kScrollBarThickness * m_scale * 0.5f, m_theme->borderStrong);
|
||||
}
|
||||
|
||||
void SignalPanel::PaintStyleSection(gd::Graphics& g)
|
||||
{
|
||||
if (m_styleRect.Height <= 0.0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
UiFillRect(g, m_styleRect, m_theme->panelBg);
|
||||
|
||||
// ---- 顶部「目标」行(无论是否已选中信号都要绘制:未选中时显示占位文本)----
|
||||
PaintTargetRow(g);
|
||||
|
||||
const SignalStyle* style = (m_tab == nullptr) ? nullptr : StyleOf(SelectedRef());
|
||||
if (style == nullptr || SelectedRef().IsValid() == false)
|
||||
{
|
||||
// 提示文本只占「目标」行以下的区域,避免与目标行重叠。
|
||||
const float top = m_targetRowRect.Y + m_targetRowRect.Height;
|
||||
UiText(g, L"请选择信号以编辑样式", m_fonts->uiSmall,
|
||||
gd::RectF(m_styleRect.X, top, m_styleRect.Width,
|
||||
std::max(0.0f, m_styleRect.Y + m_styleRect.Height - top)),
|
||||
m_theme->textMuted, TextAlign::CENTER);
|
||||
return;
|
||||
}
|
||||
|
||||
const float labelWidth = kStyleLabelWidth * m_scale;
|
||||
|
||||
// 行标签统一以整行为基准垂直居中,确保与同行控件纵向对齐。
|
||||
auto paintRowLabel = [&](int rowIndex, const wchar_t* text)
|
||||
{
|
||||
const gd::RectF& row = m_styleRowRects[static_cast<std::size_t>(rowIndex)];
|
||||
UiText(g, text, m_fonts->uiSmall, gd::RectF(row.X, row.Y, labelWidth, row.Height),
|
||||
m_theme->textMuted, TextAlign::LEFT);
|
||||
};
|
||||
|
||||
// ---- 第一行:颜色(预设色块 + 自定义颜色入口)----
|
||||
{
|
||||
paintRowLabel(0, L"颜色");
|
||||
|
||||
int paletteCount = 0;
|
||||
const std::uint32_t* palette = SignalPalette(paletteCount);
|
||||
|
||||
// 判定“当前是否在用自定义色”:必须同时满足
|
||||
// 1) 显示颜色等于记忆的自定义色;
|
||||
// 2) 该颜色不是任何一个可见的预设色。
|
||||
// 否则一旦自定义色与某个预设色相同时,点击那个预设色块会把高亮错误地
|
||||
// 挪到自定义色块上(用户报告的“点预设色直接跳到自定义色块”)。
|
||||
bool presetMatches = false;
|
||||
for (int index = 0; index < m_colorCount; ++index)
|
||||
{
|
||||
if (style->color == palette[index % paletteCount])
|
||||
{
|
||||
presetMatches = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const bool customActive = (style->color == style->customColor) && !presetMatches;
|
||||
for (int index = 0; index < m_colorCount; ++index)
|
||||
{
|
||||
const gd::RectF& rect = m_colorRects[static_cast<std::size_t>(index)];
|
||||
const std::uint32_t color = palette[index % paletteCount];
|
||||
const bool hovered = (m_hoverStyle == StyleHit::COLOR && m_hoverStyleIndex == index);
|
||||
const bool active = !customActive && (style->color == color);
|
||||
|
||||
// 预设色块同样叠加不透明度;透明度过低时自动补描边以保证可辨识(与自定义色块一致)。
|
||||
UiDrawSwatch(g, rect, color, style->opacity, kCorner * m_scale, m_scale);
|
||||
if (active || hovered)
|
||||
{
|
||||
const gd::RectF ring(rect.X - 2.0f * m_scale, rect.Y - 2.0f * m_scale,
|
||||
rect.Width + 4.0f * m_scale, rect.Height + 4.0f * m_scale);
|
||||
UiDrawRoundRect(g, ring, 5.0f * m_scale,
|
||||
active ? m_theme->textStrong : m_theme->borderStrong,
|
||||
active ? 1.6f : 1.0f);
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义颜色入口:与预设色块完全同尺寸、同外框规则的小方格,仅多一个加号图标。
|
||||
// 方格始终显示记忆的自定义颜色,因此从任意预设色再次点击它都能立即回到上次的自定义色。
|
||||
const bool chipHot = (m_hoverStyle == StyleHit::CUSTOM_CHIP);
|
||||
const std::uint32_t chipColor =
|
||||
UiDrawSwatch(g, m_customChipRect, style->customColor, style->opacity, kCorner * m_scale, m_scale);
|
||||
const std::uint32_t iconColor = IsDarkColor(chipColor) ? 0xFFFDF6E3u : 0xFF073642u;
|
||||
UiDrawPlusIcon(g, m_customChipRect, iconColor, 1.6f * m_scale);
|
||||
|
||||
// 当前正在使用自定义颜色、或鼠标悬停时,显示与预设色块一致的外框。
|
||||
if (customActive || chipHot)
|
||||
{
|
||||
const gd::RectF& chip = m_customChipRect;
|
||||
const gd::RectF ring(chip.X - 2.0f * m_scale, chip.Y - 2.0f * m_scale,
|
||||
chip.Width + 4.0f * m_scale, chip.Height + 4.0f * m_scale);
|
||||
UiDrawRoundRect(g, ring, 5.0f * m_scale,
|
||||
customActive ? m_theme->textStrong : m_theme->borderStrong,
|
||||
customActive ? 1.6f : 1.0f);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 第二行:样式(线段 / 散点 / 点线)----
|
||||
{
|
||||
paintRowLabel(1, L"样式");
|
||||
|
||||
for (int index = 0; index < kModeOptionCount; ++index)
|
||||
{
|
||||
const gd::RectF& rect = m_modeRects[static_cast<std::size_t>(index)];
|
||||
const bool active = (style->mode == kModeOptions[index]);
|
||||
const bool hovered = (m_hoverStyle == StyleHit::MODE && m_hoverStyleIndex == index);
|
||||
|
||||
UiFillRoundRect(g, rect, kCorner * m_scale,
|
||||
active ? m_theme->accentSoft : (hovered ? m_theme->hover : m_theme->cardBg));
|
||||
UiDrawRoundRect(g, rect, kCorner * m_scale, active ? m_theme->accent : m_theme->border, 1.0f);
|
||||
UiText(g, DrawModeName(kModeOptions[index]), m_fonts->uiSmall, rect,
|
||||
active ? m_theme->textStrong : m_theme->text, TextAlign::CENTER);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 第三行:调整(系数 / 镜像 / 绝对值,三个切换类按钮)----
|
||||
// 结算顺序固定为「系数 → 绝对值 → 镜像」;「系数」「镜像」点击后展开二级面板。
|
||||
{
|
||||
paintRowLabel(2, L"调整");
|
||||
|
||||
const auto paintToggle = [&](int index, const wchar_t* label, bool checked, StyleHit target)
|
||||
{
|
||||
const gd::RectF& rect = m_adjustRects[static_cast<std::size_t>(index)];
|
||||
const bool hot = (m_hoverStyle == target);
|
||||
UiFillRoundRect(g, rect, kCorner * m_scale,
|
||||
checked ? m_theme->accentSoft : (hot ? m_theme->hover : m_theme->cardBg));
|
||||
UiDrawRoundRect(g, rect, kCorner * m_scale,
|
||||
checked ? m_theme->accent : m_theme->border, 1.0f);
|
||||
UiText(g, label, m_fonts->uiSmall, rect,
|
||||
checked ? m_theme->textStrong : m_theme->text, TextAlign::CENTER);
|
||||
};
|
||||
// 系数/偏移:系数不等于默认值 1、或任一偏移不为 0 时显示为"已启用"。
|
||||
paintToggle(kAdjustGain, L"系数/偏移",
|
||||
!GainIsDefault(style->gain) || style->Offset(), StyleHit::GAIN_ADJUST);
|
||||
// 镜像:水平或垂直任一被勾选即显示为激活态。
|
||||
paintToggle(kAdjustMirror, L"镜像", style->Mirrored(), StyleHit::MIRROR_ADJUST);
|
||||
paintToggle(kAdjustAbsolute, L"绝对值", style->absolute, StyleHit::ABSOLUTE_TOGGLE);
|
||||
}
|
||||
|
||||
// ---- 第四行:线宽(数值可点击输入)----
|
||||
{
|
||||
paintRowLabel(3, L"线宽");
|
||||
|
||||
const double ratio =
|
||||
(static_cast<double>(style->lineWidth) - kWidthMin) / (kWidthMax - kWidthMin);
|
||||
const bool active = m_draggingWidth || (m_hoverStyle == StyleHit::WIDTH_SLIDER);
|
||||
UiDrawSlider(g, m_widthTrackRect, ratio, m_theme->accent, active, m_scale);
|
||||
|
||||
if (m_editField == StyleEditField::WIDTH)
|
||||
{
|
||||
UiDrawEditField(g, m_widthValueRect, m_edit, m_fonts->monoSmall, m_scale);
|
||||
}
|
||||
else
|
||||
{
|
||||
const std::wstring numberText = FormatNumber(style->lineWidth, 1);
|
||||
const std::wstring fullText = numberText + L" px";
|
||||
// 空间不足时省略单位,保证数值本身完整可读。
|
||||
const std::wstring shown =
|
||||
(UiMeasureTextWidth(m_fonts->monoSmall, fullText) >
|
||||
m_widthValueRect.Width - 6.0f * m_scale)
|
||||
? numberText
|
||||
: fullText;
|
||||
UiText(g, shown, m_fonts->monoSmall, m_widthValueRect, m_theme->textStrong, TextAlign::RIGHT);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 第五行:透明度(数值可点击输入)----
|
||||
{
|
||||
paintRowLabel(4, L"透明度");
|
||||
|
||||
const bool active = m_draggingOpacity || (m_hoverStyle == StyleHit::OPACITY_SLIDER);
|
||||
UiDrawSlider(g, m_opacityTrackRect, static_cast<double>(style->opacity), m_theme->accent, active,
|
||||
m_scale);
|
||||
|
||||
if (m_editField == StyleEditField::OPACITY)
|
||||
{
|
||||
UiDrawEditField(g, m_opacityValueRect, m_edit, m_fonts->monoSmall, m_scale);
|
||||
}
|
||||
else
|
||||
{
|
||||
const int percent = static_cast<int>(style->opacity * 100.0f + 0.5f);
|
||||
UiText(g, std::to_wstring(percent) + L"%", m_fonts->monoSmall, m_opacityValueRect,
|
||||
m_theme->textStrong, TextAlign::RIGHT);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void SignalPanel::PaintTargetRow(gd::Graphics& g)
|
||||
{
|
||||
if (m_fonts == nullptr || m_theme == nullptr || m_targetRowRect.Width <= 0.0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
UpdateTargetRowGeometry();
|
||||
|
||||
const float labelWidth = kStyleLabelWidth * m_scale;
|
||||
// 行标签与前五行一致(同一标签列宽度、同一纵向居中基准)。
|
||||
UiText(g, L"目标", m_fonts->uiSmall,
|
||||
gd::RectF(m_targetRowRect.X, m_targetRowRect.Y, labelWidth, m_targetRowRect.Height),
|
||||
m_theme->textMuted, TextAlign::LEFT);
|
||||
|
||||
const SignalRef& target = SelectedRef();
|
||||
const SignalData* signal =
|
||||
(m_dataSet != nullptr && target.IsValid()) ? m_dataSet->Resolve(target) : nullptr;
|
||||
|
||||
// 没有可编辑的目标信号:只显示占位提示(与样式区其余行的"请选择信号"提示一致)。
|
||||
if (signal == nullptr)
|
||||
{
|
||||
const float contentLeft = m_targetRowRect.X + labelWidth + kStyleLabelGap * m_scale;
|
||||
UiText(g, L"未选择信号", m_fonts->uiSmall,
|
||||
gd::RectF(contentLeft, m_targetRowRect.Y,
|
||||
std::max(0.0f, m_targetRowRect.X + m_targetRowRect.Width - contentLeft),
|
||||
m_targetRowRect.Height),
|
||||
m_theme->textMuted, TextAlign::LEFT);
|
||||
return;
|
||||
}
|
||||
|
||||
// 名称:悬停时给一层与信号列表行一致的背景高亮(用户要求"显示与交互效果相同")。
|
||||
if (m_hoverStyle == StyleHit::TARGET_NAME && m_targetNameRect.Width > 0.0f)
|
||||
{
|
||||
UiFillRoundRect(g, m_targetNameRect, kCorner * m_scale, m_theme->hover);
|
||||
}
|
||||
|
||||
if (m_editField == StyleEditField::SIGNAL_NAME && m_editRow < 0)
|
||||
{
|
||||
UiDrawEditField(g, EditFieldRect(), m_edit, m_fonts->ui, m_scale);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 颜色与信号列表行一致:已绘制到网格用常态色,未绘制用弱色。
|
||||
const std::uint32_t nameColor = (m_targetGeomLane >= 0) ? m_theme->text : m_theme->textMuted;
|
||||
// ⚠️ 这里**不能**用 `PaintRowText`(它把文本裁到 `m_listRect`,而「目标」行在样式区、
|
||||
// 列表矩形之外 ⇒ 文本会被完全裁掉,用户报告“信号名与 GX 标签都没正确绘制”)。
|
||||
UiText(g, UiEllipsize(signal->name, m_fonts->ui, m_targetNameRect.Width), m_fonts->ui,
|
||||
m_targetNameRect, nameColor, TextAlign::LEFT);
|
||||
}
|
||||
|
||||
// GX 网格标签:信号已绘制到某个网格时显示在名称**后方**(用户 2026-10-15 要求),
|
||||
// 并与其它控件一样**右对齐到内容列右边缘**。
|
||||
if (m_targetBadgeRect.Width > 0.0f)
|
||||
{
|
||||
const bool badgeHot = (m_hoverStyle == StyleHit::TARGET_BADGE);
|
||||
UiFillRoundRect(g, m_targetBadgeRect, kBadgeHeight * m_scale * 0.5f,
|
||||
badgeHot ? m_theme->accentSoft : m_theme->cardBg);
|
||||
UiDrawRoundRect(g, m_targetBadgeRect, kBadgeHeight * m_scale * 0.5f,
|
||||
badgeHot ? m_theme->accent : m_theme->border, 1.0f);
|
||||
UiText(g, L"G" + std::to_wstring(m_targetGeomLane + 1), m_fonts->monoSmall,
|
||||
m_targetBadgeRect, badgeHot ? m_theme->textStrong : m_theme->text, TextAlign::CENTER);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
} // namespace jdi
|
||||
|
||||
Reference in new issue
Block a user