Compare commits

..
5 Commits
Author SHA1 Message Date
DeepSeek V4.1 Flash 940b8e67e6 面板/拾色器布局微调 + 修复“拖拽光标飞到视界边缘”(含根因与防复发)
一、样式区「系数/偏移」二级面板:两个偏移输入框等宽对齐
- 带说明文字的 EDIT 行原来各自按自己的文本长度 + 单位后缀算宽度,于是「时间轴」
  (文本更长、还带 `s` 后缀)的输入框比「Y轴」宽一截。现在同一次排布里所有
  带说明文字的输入框共用**同一个宽度**(取其中最大自然宽度,再按“给最宽的说明文字
  留位 / 不超过内容区 3/4”夹取),因此「Y轴」与「时间轴」严格等宽、右边界对齐。
- 断言:两个输入框 126.0 / 126.0,右边界 277.8 / 277.8。

二、自定义颜色浮层(拾色器)的绘制与排版
- **圆角不再漏像素**:色域方块的位图与色相条的渐变填充都**裁剪进同一个圆角路径**
  (新增公开的 `UiBuildRoundRectPath`,与 `UiFillRoundRect` / `UiDrawRoundRect`
  逐点同源),色相条的圆角与色域方块完全一致。以前填充是直角矩形,会在圆角描边之外
  露出四个尖角(色域左下/右下本身就是接近黑色的极值,看起来就是"角落里多出黑色像素点")。
- 色域位图改用 `WrapModeTileFlipXY` 贴图:插值不再采样到边界之外,边缘也不会被压暗。
- 色相条改用**一条带 7 个色标的线性渐变**画满(颜色与原来 6 段拼色一致),
  顺带去掉段与段之间的接缝横线。
- RGB 三行:轨道**两侧间隙相等**(左:字母 ↔ 轨道;右:轨道 ↔ 数值区,共用同一常量),
  数值区固定 **3 个字符宽**(与数字本身无关),三行轨道等长、同 x,R/G/B 字母与数值
  都**右对齐**,因此三个字母、三列数字纵向对齐。
- 色号:固定 **6+1 个字符宽**,**右边界与三行数值的右边界对齐**(像素级验证:两者
  最后一个字符同列);预览色块的长宽不变、左边界与轨道左边界对齐。
- 数值/色号正好 3 / 7 个字符宽,编辑态需要把文本区向外扩出输入框内边距,否则最后
  一个字符会被输入框内边距裁掉(新增 `PaddedEditRect`,绘制、命中与插入符定位共用)。

三、修复“用选区放大把视界放大到某个临界点后,拖拽光标就飞到视界边缘”
两个独立原因,都属于“不变量只在某一个调用点维护”:
1. **吸附到采样点**没有边界检查:只要采样点间距 ≥6px 就吸附,而视界一旦比一个采样间隔还窄
   (选区放大很容易做到),“最近的采样点”就落在视界之外,于是每一次鼠标移动都把光标
   吸到视界外、再被夹回边缘 → 表现为光标“飞到视界边缘后跟不动鼠标”。现在吸附要求
   视界内至少放得下 2 个采样点,且只接受**可见区间内**的采样点。
2. **选区放大没有最小跨度下限**:滚轮缩放有 `数据跨度 × 1e-9` 的下限,选区放大没有,
   可以一直放大到视界宽度只有几个 double ULP。此时“像素 → 时间”的映射退化
   (相邻像素映射到同一个 double),鼠标一动光标就跨几百像素。现在新增
   `MinimumTimeSpan`(数据跨度 × 1e-9 与 `ULP(时间) × 画布宽度` 取大者)与
   **视界的唯一写入口 `SetTimeView`**(拒绝非有限值、纠正左右颠倒、抬到下限之上、
   再按数据范围收敛),滚轮缩放 / 选区放大 / 平移 / 适应视图 / 时间轴适应网格全部走它。
- 另新增**光标位置的唯一写入口 `PlaceCursorTime`**(拖拽光标必须经由此处):
  拒绝非有限值 → 吸附(带上限与可见区间检查)→ 夹回可见区间。`XToTime` 也补上了
  跨度退化时的兜底。
- ⚠️ 为什么会反复出现:这两个不变量(“视界必须仍然可表示/可用”“光标必须落在可见区间内、
  且对应鼠标位置”)过去是**在每个调用点各自维护**的。后来加入的路径(选区放大、采样点吸附、
  派生信号/偏移等新算子)各自绕过了其中一个环节,而故障只有在放大到极限时才可见,
  于是每加一个新功能就可能复发一次。现在视界与光标各只有一个守门人,新功能必须经过它们。

四、离屏验证(窗口隐藏、不碰屏幕/鼠标/键盘)
- 新增 [AQ]:视界比采样间隔还窄时拖光标必须跟随鼠标(修复前偏差 630px、直接贴边);
  视界内采样点仍精确吸附;连续橡胶带放大不会再突破可分辨下限(停在 1e-9 数据跨度),
  且在极限缩放下拖拽仍精确跟随(修复前 490px 偏差)。
- 新增 [AR]:色域方块与色相条四角之外不再有填充像素(角像素从填充色变为面板底色)、
  两侧间隙相等、数值列 3 字符、色号 7 字符且与数值右边界同列(像素级)、预览色块左边界
  与轨道左边界对齐、三条轨道等长同 x。
- [AP] 增加两个偏移输入框等宽/右对齐断言。
- ⚠️ 修正 harness 的一个**假通过**:`SetAllVisible(false)` 会清掉标签页的“视图已初始化”标记,
  首次渲染的 `FitAll()` 会把测试设置的深缩放覆盖掉([Z]/[AI]/[AQ] 的“深缩放”其实一直在
  整段数据上跑,所以这类回归能溜过去)。现在相关用例显式置位该标记,[Z]/[AI] 真正进入
  深缩放;并补上用例间残留弹出菜单的隔离(面板停在窗口下半部会向上翻转盖住按钮,
  吞掉下一个用例的首次点击)。
- 合计 669/669 通过(连续 4 次);`make release` 零警告(Target.exe 正在运行,链接待其关闭,
  已用临时输出名验证链接自身成功)。
2026-10-13 20:20:38 +08:00
DeepSeek V4.1 Flash fc77b0262f 「调整」行:系数/偏移 按钮 + 偏移调整(Y 轴/时间轴)二级面板;结算顺序加入偏移;派生信号继承调整变换
按钮与面板:
- 「系数」按钮改名为「系数/偏移」;点击展开的二级面板在原「系数调整」输入框下方新增「偏移调整」:
  与「系数调整」同一套绘制(标题 + 标题栏右侧的还原图标),下面两行分别是「Y轴」「时间轴」,
  每行左侧说明文字、右侧内联输入框(整行数值框)。
- 「Y轴」默认 0.00,非编辑态**始终带正负号**(+0.00 / -12.50),小数不足两位时补两位、更高精度保留更多位。
- 「时间轴」默认 0.00,非编辑态显示与双光标差值同一套复合单位文本(`Δt:500ms`、`Δt:1h 1min 2s`);
  进入编辑态时显示以**秒**为单位的数值,并在输入框最右侧提示单位 `s`。
- 「偏移调整」的还原图标把两个偏移恢复为默认值 0;「系数调整」的还原图标仍只重置系数。
- 面板/输入框的编辑初值与显示文本分离(`MenuItem::editValue` / `editSuffix`),
  非编辑态(`Δt:…`)与编辑态(`3662`)不一致时也能正确回显。

语义与结算顺序:
- 结算顺序变为 **系数 → 绝对值 → 镜像 → 偏移**(前三步不变)。
- Y 偏移:给绘制值加常数(波形上下移动);时间偏移:采样绘制在 `t + offsetTime` 处
  (例如 t=1s、偏移 -0.5s → 画在 0.5s)。
- ⚠️ Y 偏移以**未镜像空间**为准:垂直镜像开关变化时同步取反偏移量,
  使绘制等价于「先加上偏移再镜像」(开关来回切换可逆)。
- 绘制链路全部跟随偏移:`BuildPolyline`(逐点描边 / 像素列包络 / 跨列边界插值 / 粗粒度分组层,
  保序的 Y 偏移直接加在分组极值上)、派生预览曲线、光标取值读数(含 Δt)、
  可见区间 Y 轴自适应、整段自适应的区间映射;折线缓存键、场景指纹、样式快照与图层指纹都纳入两个偏移。

派生信号(实验性功能「构建为新信号」):
- 修复「预览是调整后的波形、构建出来却按原始状态绘制」:新信号**继承源信号的调整变换**
  (系数/绝对值/两个镜像/两个偏移)——网格预览绘制的是 `变换(滤波结果)`,
  而构建出的信号只存了滤波结果,因此必须把变换一并继承;
  时间轴偏移**只能**靠继承(派生信号与源信号共用同一文件时间轴,无法把时间平移烘进数据)。

离屏验证(不干扰用户):
- ⚠️ harness 现在在每次注入鼠标消息后**立即释放本窗口的鼠标捕获**
  (宿主 WM_LBUTTONDOWN 会 SetCapture,隐藏窗口同样会把用户的真实鼠标输入吸走);
  后续消息本来就是直接投递,无需捕获。
- 新增 [AP]:按钮标签不裁切、面板 5 行顺序与「偏移调整」位于系数输入框下方、
  Y轴/时间轴行的标签与输入框同行且默认值/单位提示正确、时间轴编辑态显示秒值并在最右侧提示 `s`、
  Y 偏移提交/显示正负号/波形整体平移量、时间偏移提交/Δt 复合单位(含 3662 → Δt:1h 1min 2s)/
  编辑态为秒、**按"偏移若干个采样点"构造的精确几何验证**(采样被画到移位后的像素位置、且不再画在原处)、
  读数跟随、两个还原图标、垂直镜像对 Y 偏移取反(可逆)、
  以及派生信号继承变换;[AL] 补充「系数→绝对值→镜像→偏移」的顺序与区间映射(含 Y 偏移)。
- 合计 645/645 通过;make clean && make all 零警告。
2026-10-13 17:56:00 +08:00
DeepSeek V4.1 Flash 2b02518f85 样式区「调整」行改为 系数/镜像/绝对值:系数二级面板(还原图标 + 整行输入框)、镜像二级面板(水平/垂直复选框);Y 轴自适应按变换后曲线
- 「调整」行三个按钮改为「系数」「镜像」「绝对值」(顺序即结算顺序:系数 → 绝对值 → 镜像)。
  「系数」「镜像」点击后在按钮下沿展开二级面板(按下只登记、抬起命中同一按钮才展开,
  与自定义颜色入口一致),「绝对值」仍是直接切换的按钮。
  打开面板时主动清除样式区悬停态 —— 面板会接管鼠标事件,否则该按钮会一直保持"鼠标按住"般的高亮。

- 数据层:`SignalStyle::inverted` 拆成 `mirrorVertical`(Y=0 上下翻转)与
  `mirrorHorizontal`(水平镜像 = 数据反序);新增 `Mirrored()`、
  `SourceIndexFor`(水平镜像的遍历下标 → 源下标映射)、`TransformValueRange`(区间映射)。
  `ApplyDrawTransform` 顺序固定为 系数 → 绝对值 → 垂直镜像;水平镜像在采样遍历层处理。

- 绘制链路:`BuildPolyline`(逐点描边与像素列包络两条路径、跨列边界插值、粗粒度分组层)、
  派生预览曲线、光标取值读数(含 Δt)全部按同一变换取值;水平镜像时按源下标区间反序读取,
  包络层仍复用粗粒度分组;折线缓存键、`SceneContentHash`、样式快照都带上两个镜像位。

- 系数二级面板:标题「系数调整」,标题栏最右端是一个可点击的「还原」自绘图标按钮
  (悬停有高亮反馈,点击后系数重置为 1,面板保持打开);下面是一整行输入框
  (不再显示「绘制系数」说明文字),数值至少显示两位小数、精度更高时按实际精度显示
  (1 → 1.00、2.5 → 2.50、1.2345678 原样)。

- 镜像二级面板:两行带复选框的切换项「水平镜像」「垂直镜像」,点击后**面板保持打开**
  (`MenuItem::keepOpen`),勾选状态就地回写;任一项勾选时「镜像」按钮显示为激活态。

- Y 轴自适应修复:整段自适应(`TabConfig::ComputeLaneRange`)按区间映射(`TransformValueRange`)
  计算,不再直接用原始 min/max;可见区间自适应(`AutoRangeLaneToView`)逐点套用绘制变换
  (含水平镜像的源下标映射)。用户报告的"调整系数/镜像后自适应区域错误、仍按原始数据"由此修复。

- `PopupMenu`:新增 `MenuItemKind::HEADER_ACTION`(标题 + 右侧可点击图标,图标有悬停高亮)、
  `MenuItem::keepOpen`(切换项点击后不关闭菜单)、`HeaderIconRect()`,
  以及无说明文字的整行输入框(占满内容区)。

验证(离屏 harness,SW_HIDE、不动鼠标键盘屏幕):
- [AL] 重写/扩充:变换顺序与区间映射(含水平镜像的源下标反序)、调整行三按钮的顺序/等宽/标签不裁切、
  「系数」按住不弹出 / 移开取消 / 单击展开(标题 HEADER_ACTION、无标题输入框占满整行、初始 1.00、
  两位小数与更高精度回显、提交后面板保持打开、折线缓存键携带系数)、
  「还原」图标位置/悬停像素变化/点击后系数回到 1.00 且面板保持打开、
  「镜像」面板两行复选框(初始未勾选、keepOpen、勾选后按钮激活、取消其一保留另一)、
  打开面板后按钮不再停留高亮、系数/镜像按钮的激活填充与其它启用态切换按钮逐像素同色;
  `BuildPolyline` 顶点范围与变换后数据范围逐项对齐(默认/系数 2/绝对值/垂直镜像/三者叠加),
  水平镜像用窄视窗逐顶点核对"取的是对面端点的采样"(8/8)+ 读数跟随;
  以及 Y 轴自适应:整段/可见区间自适应在系数、绝对值、垂直镜像下均跟随变换后的曲线。
- 合计 594/594 通过;make clean && make all 零警告。
2026-10-13 14:50:38 +08:00
DeepSeek V4.1 Flash 2f243dd557 样式区「调整」行:系数调整(二级面板输入)+ 取绝对值 + 中心反转
- 移除上一轮新增的「系数增益」输入行与「取绝对值 / 中心反转」按钮行。
- 在「样式」下方、「线宽」上方新增「调整」行,一行三个等宽按钮:
  「系数调整」「取绝对值」「中心反转」(后两者即原按钮迁移至此)。
  样式区固定行数回到五行(颜色 / 样式 / 调整 / 线宽 / 透明度),标签列宽回到 44(只需容纳「调整」)。
- 「系数调整」交互与"自定义颜色色块入口"一致:按下只登记,**抬起**且指针仍在同一按钮上才展开
  二级面板;面板锚定在按钮下沿,内含一个数值输入框(默认 1,EditFilter::NUMBER)。
  提交后面板保持打开(与网格 Y 轴上下限的菜单内联编辑一致),并把夹取后的真实值回写输入框。
- 按钮启用态规则:绘制系数 != 1 时显示为"已启用"(与其它切换按钮启用样式完全一致:
  accentSoft 底 + accent 描边),等于默认值 1 时为"未启用"样式。
- 系数为 1 / 非保序变换的回退逻辑、SceneContentHash / 样式快照 / 折线缓存键等绘制链路不变。

验证(离屏 harness,SW_HIDE、不动鼠标键盘屏幕):
- [AL] 重构为:五行高度与行序(调整行在样式行与线宽行之间)、三按钮按序铺满且不重叠、
  三个标签都完整放得下(无裁切);
  「系数调整」按住不弹出 / 移开再松开取消 / 单击展开(SIGNAL_PANEL 范围、标题「系数调整」、
  初始值 1、锚定在按钮下沿)→ 点击输入框进入编辑 → 键入 2.5 回车提交(面板保持打开、
  输入框回显 2.5、折线缓存键携带新系数)→ 点别处关闭面板;
  像素级核对按钮样式:gain=1 时填充为 cardBg,gain=2.5 时填充与"取绝对值已启用"的按钮**逐像素同色**
  (0xFFCFE4F3)且与未启用态不同。
- 其余用例(样式变换顺序与顶点范围、拾色器两列等高、选区放大下 GX/图例命中、标题栏内存读数)
  保持不变。合计 549/549 通过;make clean && make all 零警告。
2026-10-13 13:51:31 +08:00
DeepSeek V4.1 Flash c3343907ec 标题栏内存占用 / 样式区增益与取绝对值·中心反转 / 拾色器排版 / 图例去托底与命中优先级
标题栏:
- 应用名称后追加「  |  RAM : <值>」:竖线两侧各留两个空格(与分组标题「组X | 组名」的间距风格一致)。
- 数值按 KB/MB/GB 自适应、保留两位小数(复用 FormatByteSize);用 K32GetProcessMemoryInfo 取本进程
  工作集(与任务管理器「内存」一致,无需新增链接依赖);1 秒定时器刷新,数值未变时不调用 SetWindowTextW。

信号样式自定义栏:
- 线宽与透明度交换上下位置(线宽在上、透明度在下)。
- 新增「系数增益」:输入框(默认 1,只接受数值,范围 ±10000),点击进入编辑、回车/点击别处提交。
- 新增「取绝对值」「中心反转」两个同行的切换按钮。
- 三者作用顺序固定为「系数增益 → 取绝对值 → 中心反转」(中心反转最后结算),实现为
  ApplyDrawTransform(style, value),在 BuildPolyline(逐点描边与像素列包络两条路径,含粗粒度分组层)、
  派生预览曲线与光标取值读数统一调用;样式属于信号本身(跨视图共享)并进入 SceneContentHash /
  SceneStyleEntry / 折线缓存键,改动会立即重绘。
- 非保序变换(取绝对值 / 反转 / 负增益)会回退到逐采样点变换、不再使用粗粒度包络层,保证包络精确。

自定义颜色面板:
- 把底部的「色号 + 预览色块」移到 RGB 滑块上方;右侧一列(预览行 + 三行滑块)与左侧「色域方块 +
  色相条」高度严格相等,两列上下边对齐,面板不再为它单独保留一行。

网格内:
- 去掉图例与 GX 编号下方的整块不透明托底(它会把网格线整片擦掉,看起来像一个"可点击控件",
  而其右侧空白实际并不响应点击)。
- 图例与 GX 徽标改为优先命中:先于时间光标线 / 光标读数 / 边沿计数横向光标 / 选区放大判定,
  因此选区放大模式下也能正常拖拽 GX 标签与点选图例(浮动面板仍然优先,与绘制层序一致)。

验证:离屏 harness(.preflight/uitest,SW_HIDE、不动鼠标键盘屏幕)新增
[AK] 标题栏内存读数(格式与竖线间隙、单位阈值、两位小数、随 256MB 分配增长、定时器刷新);
[AL] 样式区行序与几何、增益输入提交、两个切换按钮的真实点击、ApplyDrawTransform 顺序,以及
BuildPolyline 顶点范围与变换后数据范围逐项对齐(默认 / 增益 2 / 取绝对值 / 中心反转 /
负增益+取绝对值+中心反转叠加);
[AM] 拾色器两列严格等高(140.00 == 140.00);
[AN] 选区放大模式下 GX 拖拽重排 + 图例点选 + 网格线穿过图例带(托底已移除)。
另修正旧用例 [AI](远端已删除"光标跟随视界",改为断言光标时间位置不变、允许滑出视窗)。
合计 530/530 通过;make clean && make all 零警告。
2026-10-13 12:06:38 +08:00
16 changed files with 1847 additions and 403 deletions

No files matched your search

@@ -395,6 +395,7 @@ namespace jdi
Target m_activeTarget = Target::NONE; /**< 当前鼠标作用目标。 */
bool m_trackingLeave = false; /**< 是否已注册鼠标离开通知。 */
bool m_hintTimerActive = false; /**< 提示浮层淡出定时器是否已启动。 */
std::wstring m_windowTitleText; /**< 上次写入窗口标题的完整文本(用于避免重复设置)。 */
int m_tabMenuIndex = -1; /**< 标签栏右键菜单的目标标签页下标。 */
int m_plotMenuLane = -1; /**< 绘图区右键菜单的目标网格下标。 */
+91 -4
View File
@@ -6,6 +6,7 @@
#include <thread>
#include <commdlg.h>
#include <psapi.h>
#include <shellapi.h>
#include <windowsx.h>
@@ -27,7 +28,16 @@ namespace jdi
namespace
{
const wchar_t* kWindowClassName = L"JustDataInspectorMainWindow"; /**< 窗口类名。 */
const wchar_t* kWindowTitle = L"Just DataInspector"; /**< 窗口标题(仅显示应用名称)。 */
const wchar_t* kWindowTitle = L"Just DataInspector"; /**< 窗口标题中的应用名称部分。 */
/**
* @brief 应用名称与内存占用之间的分隔竖线。
*
* 与信号分组标题里「组X | 组名」的分隔方式一致:竖线两侧各留出空隙
*(`kGroupSeparatorGap` 是那个分隔线的左右间距,这里用两个空格达到同等观感)。
*/
const wchar_t* kTitleSeparator = L" | ";
/** @brief 内存占用的显示前缀(与具体数值之间保留一个空格)。 */
const wchar_t* kRamTitlePrefix = L"RAM : ";
const float kSplitterWidth = 4.0f; /**< 分隔条宽度(逻辑像素)。 */
const float kMinPanelWidth = 260.0f; /**< 面板最小宽度(逻辑像素)。 */
const float kMaxPanelWidth = 400.0f; /**< 面板最大宽度(逻辑像素)。 */
@@ -37,6 +47,29 @@ namespace jdi
const UINT kMessageRemoveSignal = WM_APP + 2; /**< 面板请求移除单个信号的自定义消息。 */
const UINT_PTR kHintTimerId = 1; /**< 提示浮层淡出用的定时器 ID。 */
const UINT kHintTimerPeriodMs = 33; /**< 提示浮层淡出的重绘间隔(约 30fps)。 */
const UINT_PTR kRamTimerId = 2; /**< 标题栏内存占用刷新的定时器 ID。 */
const UINT kRamTimerPeriodMs = 1000; /**< 标题栏内存占用的刷新间隔(1 秒)。 */
/**
* @brief 查询本进程当前占用的物理内存(工作集)。
*
* 与任务管理器里显示的“内存”一致:进程已驻留在物理内存中的字节数。
* 使用 `K32GetProcessMemoryInfo`(kernel32 导出)以免为 `psapi` 增加一条链接依赖。
*
* @return 工作集字节数;查询失败返回 0。
*/
unsigned long long ProcessWorkingSetBytes()
{
PROCESS_MEMORY_COUNTERS_EX counters = {};
counters.cb = sizeof(counters);
if (K32GetProcessMemoryInfo(GetCurrentProcess(),
reinterpret_cast<PROCESS_MEMORY_COUNTERS*>(&counters),
sizeof(counters)) == FALSE)
{
return 0ull;
}
return static_cast<unsigned long long>(counters.WorkingSetSize);
}
/**
* @brief 只失效绘图区所在的矩形。
@@ -345,6 +378,8 @@ namespace jdi
NewTab();
Relayout();
// 标题栏内存占用按固定间隔刷新(不使用每帧刷新,避免频繁改动窗口标题)。
SetTimer(m_hwnd, kRamTimerId, kRamTimerPeriodMs, nullptr);
ShowWindow(m_hwnd, showCommand);
UpdateWindow(m_hwnd);
LoadCommandLineFiles();
@@ -862,8 +897,19 @@ namespace jdi
void Application::UpdateWindowTitle()
{
// 标题栏只显示应用名称,不附带标签页、文件数量等无关信息。
SetWindowTextW(m_hwnd, kWindowTitle);
// 标题栏 = 应用名称 + 分隔竖线 + 内存占用(单位在 KB/MB/GB 间自适应,保留两位小数)。
// 除内存占用外不再附带标签页、文件数量等无关信息。
std::wstring title = kWindowTitle;
title += kTitleSeparator;
title += kRamTitlePrefix;
title += FormatByteSize(ProcessWorkingSetBytes());
// 数值未变化时不调用 SetWindowTextW,避免无谓地重绘标题栏。
if (title == m_windowTitleText)
{
return;
}
m_windowTitleText = title;
SetWindowTextW(m_hwnd, m_windowTitleText.c_str());
}
void Application::OpenFileDialog()
@@ -1479,6 +1525,29 @@ namespace jdi
const SignalData& source = file->signals[static_cast<std::size_t>(request.signalIndex)];
const std::wstring baseName = source.Title();
// 0. 先把源信号的「调整」变换快照下来(下面 `InsertDerivedSignal` 会移动信号存储,
// 之后再取 `source` 会悬垂)。
//
// ⚠️ **新信号必须继承这套变换**,否则会出现“预览是调整后的波形、构建出来的却按原始状态绘制”
//(用户报告的不一致):网格里的预览曲线绘制的是 `变换(滤波结果)`,而构建出的信号只存了
// `滤波结果`;把变换一并继承后,两者绘制完全相同。
// 另外**时间轴偏移只能靠继承**:派生信号与源信号共用同一个文件时间轴,无法把时间平移烘进数据。
SignalStyle inheritedTransform;
{
SignalRef sourceRef;
sourceRef.fileIndex = request.fileIndex;
sourceRef.signalIndex = request.signalIndex;
if (const SignalStyle* sourceStyle = m_dataSet.Style(sourceRef))
{
inheritedTransform.gain = sourceStyle->gain;
inheritedTransform.absolute = sourceStyle->absolute;
inheritedTransform.mirrorVertical = sourceStyle->mirrorVertical;
inheritedTransform.mirrorHorizontal = sourceStyle->mirrorHorizontal;
inheritedTransform.offsetY = sourceStyle->offsetY;
inheritedTransform.offsetTime = sourceStyle->offsetTime;
}
}
// 1. 计算派生采样值(与网格内的预览曲线共用同一实现,因此逐位一致)。
// 多次迭代均值滤波形如「<原信号名称> (N点均值 M次迭代)」,
// 弱峰调整形如「<原信号名称> (弱峰调整 M% N次迭代)」。
@@ -1498,7 +1567,7 @@ namespace jdi
std::to_wstring(request.iterations) + L"次迭代)";
}
// 2. 组装新信号。
// 2. 组装新信号(外观先沿用默认值,插入后再继承源信号的「调整」变换)。
derived.rawHeader = derived.name;
derived.unit = source.unit;
derived.values.assign(derived_values.begin(), derived_values.end());
@@ -1517,6 +1586,18 @@ namespace jdi
tab.RemapAfterSignalInsert(request.fileIndex, newIndex);
}
// 3b. 新信号继承源信号的「调整」变换(系数 / 绝对值 / 镜像 / 偏移),
// 使其绘制结果与网格里的预览曲线**完全一致**。
if (SignalStyle* derivedStyle = m_dataSet.Style(SignalRef{request.fileIndex, newIndex}))
{
derivedStyle->gain = inheritedTransform.gain;
derivedStyle->absolute = inheritedTransform.absolute;
derivedStyle->mirrorVertical = inheritedTransform.mirrorVertical;
derivedStyle->mirrorHorizontal = inheritedTransform.mirrorHorizontal;
derivedStyle->offsetY = inheritedTransform.offsetY;
derivedStyle->offsetTime = inheritedTransform.offsetTime;
}
// 4. 在当前标签页中勾选新信号,并把它所在的网格移到原信号网格的下方。
if (makeVisible && !m_tabs.empty())
{
@@ -2748,6 +2829,12 @@ namespace jdi
}
case WM_TIMER:
{
// 标题栏内存占用:只改窗口标题,不动客户区,因此无需重绘。
if (wParam == kRamTimerId)
{
UpdateWindowTitle();
return 0;
}
// 提示浮层淡出:仅重绘,不再做其它处理;Render 会在淡出结束后停表。
if (wParam == kHintTimerId)
{
+70 -2
View File
@@ -291,6 +291,13 @@ namespace jdi
* 仅在横向放大到采样点间距足够大(可精确分辨)时生效,
* 避免缩小时光标出现“抖动”难以微调。
*
* ⚠️ 吸附有**上下两个门限**:
* * 下门限:采样点间距 ≥ `kCursorSnapMinPixels`(缩小时不吸附,否则难以微调);
* * 上门限:视界内至少能容纳 **2 个**采样点(`pixels <= plotWidth / 2`)。
* 视界比采样间隔还窄时“最近采样点”在视界之外,吸附会把光标一把甩到视界边缘
* 并钉住(表现为“一拖光标就飞到视界边缘、跟不动鼠标”)。
* * 并且只接受**落在当前可见区间内**的采样点。
*
* @param [IN] time 原始时间。
* @param [OUT] snapped 吸附后的时间。
* @return 成功吸附返回 true。
@@ -925,6 +932,49 @@ namespace jdi
*/
void ClampCursorToView(int which);
/**
* @brief 计算当前数据下**最小可用的时间跨度**(时间视界的下限)。
*
* 下限由两件事共同决定:
* * 数据本身:`数据跨度 × 1e-9`(与滚轮缩放一致)——小于它就是把视界缩进采样间隔内部;
* * `double` 的可表示精度:视界宽度小于 `ULP(时间) × 画布宽度` 时,
* 「像素 → 时间」的映射会退化(相邻像素映射到同一个 double),
* 鼠标每动一下只能落在少数几个可表示的时间值上,
* 表现为**光标跟不上鼠标、一拖就跨几百像素或直接贴到视界边缘**。
*
* @param [IN] low 视界下限(用于估算时间量级)。
* @param [IN] high 视界上限。
* @return 最小可用跨度。
*/
double MinimumTimeSpan(double low, double high) const;
/**
* @brief **时间视界的唯一写入入口**(所有不变量的守门人)。
*
* 负责:拒绝非有限值、纠正左右颠倒、把跨度抬到 `MinimumTimeSpan` 之上、
* 以及最后按数据范围做边界收敛。
*
* ⚠️ 缩放(滚轮 / 选区放大)、平移、适应视图都必须经过这里:
* 过去每个路径各自维护“跨度下限”,新增的路径(如选区放大)漏掉了这件事,
* 于是“能一直放大到视界只有几个 ULP”这类问题反复出现。
*
* @param [IN] low 新的视界下限。
* @param [IN] high 新的视界上限。
*/
void SetTimeView(double low, double high);
/**
* @brief **光标时间位置的统一写入入口**(拖拽光标时必须经由此处)。
*
* 负责:拒绝非有限值、按 `SnapTimeToSample` 吸附加视界内采样点、
* 以及把结果夹回可见区间。三步缺一不可——光标必须在**可见区间内**且与鼠标位置对应,
* 否则用户看到的是“拖拽时光标自己飞到视界边缘”。
*
* @param [IN] which 光标序号(1 或 2)。
* @param [IN] time 目标时间(鼠标位置换算)。
*/
void PlaceCursorTime(int which, double time);
/**
* @brief 计算双光标之间的时间差(绝对值)。
* @return 时间差;非双光标模式返回 0。
@@ -1443,9 +1493,11 @@ namespace jdi
* @param [IN] count 可用采样点数。
* @param [OUT] begin 区间起点(含)。
* @param [OUT] end 区间终点(不含)。
* @param [IN] timeShift 信号的时间偏移:采样画在 `t + timeShift` 处,因此可见采样对应的
* 源时刻区间为 `[ViewMin - timeShift, ViewMax - timeShift]`(偏移 0 时同视界)。
*/
void VisibleIndexRange(const DataFile& file, std::size_t count, std::size_t& begin,
std::size_t& end) const;
std::size_t& end, double timeShift = 0.0) const;
/**
* @brief 绘制某条信号的多次迭代均值滤波曲线(与中心平均值曲线同一配色派生规则)。
@@ -1838,6 +1890,12 @@ namespace jdi
float laneHeight = 0.0f; /**< 网格高度。 */
double laneYMin = 0.0; /**< 网格幅值下界。 */
double laneYMax = 0.0; /**< 网格幅值上界。 */
float gain = 1.0f; /**< 绘制前的系数。 */
bool absolute = false; /**< 绘制前是否取绝对值。 */
bool mirrorVertical = false; /**< 绘制前是否按 Y=0 上下镜像。 */
bool mirrorHorizontal = false; /**< 是否水平镜像(取值下标反序)。 */
float offsetY = 0.0f; /**< Y 轴偏移。 */
double offsetTime = 0.0; /**< 时间轴偏移(秒)。 */
std::vector<gd::PointF> points; /**< 折线顶点。 */
std::vector<std::size_t> breaks; /**< 每段折线在 points 中的起始下标。 */
@@ -2001,12 +2059,22 @@ namespace jdi
float opacity = 1.0f; /**< 不透明度。 */
float lineWidth = 1.0f; /**< 线宽。 */
int mode = 0; /**< 绘制模式。 */
float gain = 1.0f; /**< 系数。 */
bool absolute = false; /**< 是否取绝对值。 */
bool mirrorVertical = false; /**< 是否按 Y=0 上下镜像。 */
bool mirrorHorizontal = false; /**< 是否水平镜像(数据反序)。 */
float offsetY = 0.0f; /**< Y 轴偏移。 */
double offsetTime = 0.0; /**< 时间轴偏移(秒)。 */
/** @brief 相等比较(用于判断"逐信号外观样式是否变化")。 */
bool operator==(const SceneStyleEntry& other) const
{
return color == other.color && opacity == other.opacity &&
lineWidth == other.lineWidth && mode == other.mode;
lineWidth == other.lineWidth && mode == other.mode &&
gain == other.gain && absolute == other.absolute &&
mirrorVertical == other.mirrorVertical &&
mirrorHorizontal == other.mirrorHorizontal && offsetY == other.offsetY &&
offsetTime == other.offsetTime;
}
};
+295 -268
View File
@@ -1,4 +1,4 @@
#include "PlotView.hpp"
#include "PlotView.hpp"
#include <algorithm>
#include <cmath>
@@ -205,13 +205,15 @@ namespace jdi
* @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。
*/
bool PolylineValueAt(const std::vector<double>& time, const std::vector<double>& values,
std::size_t count, std::size_t firstIndex, double t, double& out)
std::size_t count, const SignalStyle& style, std::size_t firstIndex,
double t, double& out)
{
if (firstIndex == 0 || firstIndex >= count)
{
@@ -220,8 +222,9 @@ namespace jdi
}
const double t0 = time[firstIndex - 1];
const double t1 = time[firstIndex];
const double v0 = values[firstIndex - 1];
const double v1 = values[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))
{
@@ -498,143 +501,6 @@ namespace jdi
}
}
/**
* @brief 时间间隔的显示单位。
*/
struct DurationUnit
{
double scale = 1.0; /**< 1 个该单位等于多少秒。 */
const wchar_t* name = L"s"; /**< 单位名称。 */
};
/**
* @brief 把时间间隔格式化为"Δt 语义"的复合单位文本。
*
* 规则(用户明确要求):
* - 顶级单位高于 s(即 h / min)时**一路整合到 s**,例如 3662s → "1h 1min 2s";
* - 顶级单位为 s 及以下时,若不是该单位的整数,则**向下一级递进**,
* 例如 5.06s → "5s 60ms";整秒数不加下级单位,例如 5s → "5s";
* - 顶级单位为 ps 时**不再递进次级单位**,例如 5.123ps → "5.123 ps"。
*
* @param [IN] seconds 时间间隔(秒)。
* @return 不含 "Δt" 前缀的间隔文本。
*/
std::wstring FormatDurationCompound(double seconds)
{
if (!std::isfinite(seconds))
{
return L"—";
}
const double value = std::fabs(seconds);
if (value <= 0.0)
{
return L"0s";
}
// ---- 顶级单位高于 s(h / min):整合为 h/min/s(整数秒)----
// 注意条件必须是 >= 60s:1~60s 属于"顶级单位为 s"的情况,需按下一条规则处理
// (非整数秒要递进到 ms,例如 5.06s → "5s 60ms")。
if (value >= 60.0)
{
const long long totalSeconds = static_cast<long long>(std::llround(value));
long long hours = 0;
long long minutes = 0;
long long secs = totalSeconds;
if (totalSeconds >= 3600)
{
hours = totalSeconds / 3600;
secs = totalSeconds % 3600;
}
if (secs >= 60)
{
minutes = secs / 60;
secs = secs % 60;
}
std::wstring text;
const auto append = [&text](long long amount, const wchar_t* unit)
{
if (amount <= 0)
{
return;
}
if (!text.empty())
{
text += L" ";
}
text += std::to_wstring(amount);
text += unit;
};
append(hours, L"h");
append(minutes, L"min");
append(secs, L"s");
if (!text.empty())
{
return text;
}
}
// ---- 顶级单位为 s:整数秒 + 可选 ms ----
if (value >= 1.0)
{
const double whole = std::floor(value);
const double fraction = value - whole;
std::wstring text = FormatValue(whole) + L"s";
const double remainderMs = fraction * 1000.0;
if (remainderMs >= 1.0e-6)
{
text += L" ";
text += FormatValue(remainderMs);
text += L"ms";
}
return text;
}
// ---- 顶级单位低于 s:ms / us / ns / ps ----
const DurationUnit subUnits[] = {
{1.0e-3, L"ms"}, {1.0e-6, L"us"}, {1.0e-9, L"ns"}, {1.0e-12, L"ps"},
};
const std::size_t subCount = sizeof(subUnits) / sizeof(subUnits[0]);
std::size_t index = subCount - 1;
for (std::size_t i = 0; i < subCount; ++i)
{
if (value >= subUnits[i].scale)
{
index = i;
break;
}
}
// 顶级单位为 ps:不再递进次级单位(保留小数)。
if (index + 1 >= subCount)
{
return FormatValue(value / subUnits[index].scale) + L" " + subUnits[index].name;
}
const double scaled = value / subUnits[index].scale;
double whole = std::floor(scaled);
double fraction = scaled - whole;
// 浮点误差可能导致 fraction 极接近 1(例如 4.9999999),归一化一下。
if (fraction >= 1.0 - 1.0e-9)
{
whole += 1.0;
fraction = 0.0;
}
std::wstring text = FormatValue(whole);
text += subUnits[index].name;
// 仍是该单位的整数 → 不显示下级单位。
const double remainder = fraction * subUnits[index].scale;
const double inNext = remainder / subUnits[index + 1].scale;
if (inNext >= 1.0e-6)
{
text += L" ";
text += FormatValue(inNext);
text += subUnits[index + 1].name;
}
return text;
}
/**
* @brief 双光标差值的完整显示文本("Δt:" + 复合单位间隔)。
* @param [IN] seconds 时间间隔(秒)。
@@ -659,23 +525,6 @@ namespace jdi
return FormatValue(seconds) + L"s";
}
/**
* @brief 把时间间隔格式化为"秒"数值文本(不含单位),用于内联编辑。
*
* 编辑时的量级**始终是秒**:即使显示为 "1.5min",双击编辑的内容也是 90。
*
* @param [IN] seconds 时间间隔(秒)。
* @return 形如 "60" 的文本。
*/
std::wstring FormatDurationSeconds(double seconds)
{
if (!std::isfinite(seconds))
{
return L"0";
}
return FormatValue(std::fabs(seconds));
}
/**
* @brief 按比例缩放颜色的 alpha 通道(用于提示浮层淡出)。
* @param [IN] argb 原始 0xAARRGGBB 颜色。
@@ -1027,12 +876,13 @@ namespace jdi
double PlotView::XToTime(float x) const
{
const double viewMin = Tab().ViewMin();
if (m_plotRect.Width <= 0.0f)
const double span = Tab().ViewMax() - viewMin;
if (m_plotRect.Width <= 0.0f || !(span > 0.0))
{
return viewMin;
}
const double ratio = static_cast<double>(x - m_plotRect.X) / static_cast<double>(m_plotRect.Width);
return viewMin + ratio * (Tab().ViewMax() - viewMin);
return viewMin + ratio * span;
}
float PlotView::ValueToY(double value, const LaneVisual& lane) const
@@ -1105,9 +955,57 @@ namespace jdi
}
}
double PlotView::MinimumTimeSpan(double low, double high) const
{
double dataMin = 0.0;
double dataMax = 1.0;
TimeBounds(dataMin, dataMax);
const double dataSpan = std::max(dataMax - dataMin, kMinTimeSpan);
// double 在“时间量级”上的最小刻度(ULP):视界宽度低于 `ULP × 画布宽度` 时,
// 一列像素对应的 double 已经无法区分,映射彻底退化。
const double magnitude = std::max(std::fabs(low), std::fabs(high));
const double ulp =
std::nextafter(magnitude, std::numeric_limits<double>::infinity()) - magnitude;
const double width = std::max(64.0, static_cast<double>(m_plotRect.Width));
return std::max(dataSpan * 1.0e-9, std::max(ulp, kMinTimeSpan) * width);
}
void PlotView::SetTimeView(double low, double high)
{
if (!std::isfinite(low) || !std::isfinite(high))
{
return;
}
if (high < low)
{
std::swap(low, high);
}
const double minimum = MinimumTimeSpan(low, high);
if (!(high - low >= minimum))
{
// 低于下限:以中心为锚点对称展开(而不是静默地放大到不可表示)。
const double center = (low + high) * 0.5;
low = center - minimum * 0.5;
high = center + minimum * 0.5;
if (!(high - low > 0.0))
{
// 连下限都无法表示(时间量级远超可分辨范围):放弃这次修改,
// 保留上一个仍然可用的视界比制造一个空视界更安全。
return;
}
}
Tab().ViewMin() = low;
Tab().ViewMax() = high;
ClampTimeView();
}
void PlotView::ZoomTimeAt(double factor, double anchorTime)
{
if (m_dataSet == nullptr || m_dataSet->Empty() || !(factor > 0.0))
if (m_dataSet == nullptr || m_dataSet->Empty() || !(factor > 0.0) || !std::isfinite(anchorTime))
{
return;
}
@@ -1118,12 +1016,18 @@ namespace jdi
const double dataSpan = std::max(dataMax - dataMin, kMinTimeSpan);
const double span = Tab().ViewMax() - Tab().ViewMin();
double newSpan = ClampDouble(span / factor, dataSpan * 1.0e-9, dataSpan * 100.0);
if (!(span > 0.0))
{
return;
}
// 下限与选区放大共用同一个来源(见 `MinimumTimeSpan`),
// 否则“滚轮缩放有限制、选区放大没有”会让视界退化到不可用。
const double minimum = MinimumTimeSpan(Tab().ViewMin(), Tab().ViewMax());
const double newSpan = ClampDouble(span / factor, minimum, dataSpan * 100.0);
const double ratio = (span > 0.0) ? (anchorTime - Tab().ViewMin()) / span : 0.5;
Tab().ViewMin() = anchorTime - ratio * newSpan;
Tab().ViewMax() = Tab().ViewMin() + newSpan;
ClampTimeView();
const double ratio = (anchorTime - Tab().ViewMin()) / span;
const double low = anchorTime - ratio * newSpan;
SetTimeView(low, low + newSpan);
}
void PlotView::ZoomTimeStep(double factor)
@@ -1136,9 +1040,7 @@ namespace jdi
{
const double span = Tab().ViewMax() - Tab().ViewMin();
const double delta = span * fraction;
Tab().ViewMin() += delta;
Tab().ViewMax() += delta;
ClampTimeView();
SetTimeView(Tab().ViewMin() + delta, Tab().ViewMax() + delta);
}
void PlotView::ScrollLanesByFraction(double fraction)
@@ -1152,8 +1054,7 @@ namespace jdi
{
if (m_dataSet == nullptr || m_dataSet->Empty())
{
Tab().ViewMin() = 0.0;
Tab().ViewMax() = 1.0;
SetTimeView(0.0, 1.0);
Tab().ViewInitialized() = true;
return;
}
@@ -1168,8 +1069,7 @@ namespace jdi
high += padding;
}
Tab().ViewMin() = low;
Tab().ViewMax() = high;
SetTimeView(low, high);
Tab().ViewInitialized() = true;
Tab().LaneScroll() = 0.0f;
// 只放下进行中的选区与拖拽,**不改变「选区放大」等缩放模式的启用状态**
@@ -1458,8 +1358,7 @@ namespace jdi
return false;
}
Tab().ViewMin() = low;
Tab().ViewMax() = high;
SetTimeView(low, high);
Tab().ViewInitialized() = true;
// 与“适应视图”保持一致:退出选区放大模式,避免模式按钮与实际状态不符。
Tab().ZoomMode() = PlotZoomMode::NONE;
@@ -1555,6 +1454,12 @@ namespace jdi
}
const double minPixels = static_cast<double>(kCursorSnapMinPixels) * static_cast<double>(m_scale);
// 吸附的上门限:视界内至少要放得下 2 个采样点。
// 视界比采样间隔还窄时(选区放大很容易做到),"最近采样点"落在视界之外或半个视界之外,
// 吸附会把光标一把甩到视界边缘并钉住 —— 用户报告的"拖光标直接飞到视界外"。
const double maxPixels = 0.5 * static_cast<double>(m_plotRect.Width);
const double viewMin = Tab().ViewMin();
const double viewMax = Tab().ViewMax();
bool found = false;
double bestDistance = std::numeric_limits<double>::infinity();
@@ -1564,7 +1469,7 @@ namespace jdi
{
// 只有采样点在屏幕上足够稀疏时才吸附,否则缩小视图后光标会“跳格”。
double pixels = 0.0;
if (!SampleSpacingPixels(file, time, pixels) || pixels < minPixels)
if (!SampleSpacingPixels(file, time, pixels) || pixels < minPixels || pixels > maxPixels)
{
continue;
}
@@ -1579,6 +1484,11 @@ namespace jdi
{
continue;
}
// 只吸附**当前可见区间内**的采样点:视界之外的采样点不是合法的落点。
if (candidate < viewMin || candidate > viewMax)
{
continue;
}
const double distance = std::fabs(candidate - time);
if (distance < bestDistance)
@@ -1596,6 +1506,32 @@ namespace jdi
return found;
}
void PlotView::PlaceCursorTime(int which, double time)
{
if (!std::isfinite(time))
{
// NaN/Inf 会让后面所有比较都失效(`ClampDouble` 也拦不住 NaN),
// 光标会被画到画布之外:直接丢弃这次位置更新。
return;
}
double snapped = 0.0;
if (SnapTimeToSample(time, snapped))
{
time = snapped;
}
if (which == 2)
{
Tab().CursorTimeAlt() = time;
}
else
{
Tab().CursorTime() = time;
}
ClampCursorToView(which);
}
PlotZoomMode PlotView::CurrentZoomMode() const
{
return Tab().ZoomMode();
@@ -1779,9 +1715,11 @@ namespace jdi
return false;
}
Tab().ViewMin() = timeA;
Tab().ViewMax() = timeB;
ClampTimeView();
// 统一入口:内含最小跨度下限。
// ⚠️ 选区放大过去**没有**这个下限,可以一直放大到视界宽度只有几个 double ULP;
// 到那时「像素 → 时间」已经退化(相邻像素映射到同一个 double),
// 用户看到的就是“拖光标跟不动、一下跳到视界边缘/几百像素外”。
SetTimeView(timeA, timeB);
// 注意:不在这里移动光标——即使选区不包含光标,也只把它的读数贴在网格边缘显示。
// 「选区 Y 轴自适应」开关开启的网格:时间选区放大后自动把 Y 轴匹配到新的可见区间。
@@ -2201,7 +2139,7 @@ namespace jdi
}
void PlotView::VisibleIndexRange(const DataFile& file, std::size_t count, std::size_t& begin,
std::size_t& end) const
std::size_t& end, double timeShift) const
{
begin = 0;
end = count;
@@ -2209,15 +2147,16 @@ namespace jdi
{
return;
}
// 时间偏移:采样画在 `t + timeShift` 处,因此落在当前视界内的采样其源时刻区间要整体左移。
const std::vector<double>& time = file.time;
begin = static_cast<std::size_t>(
std::lower_bound(time.begin(), time.end(), Tab().ViewMin()) - time.begin());
std::lower_bound(time.begin(), time.end(), Tab().ViewMin() - timeShift) - time.begin());
if (begin > 0)
{
--begin;
}
end = static_cast<std::size_t>(
std::lower_bound(time.begin(), time.end(), Tab().ViewMax()) - time.begin());
std::lower_bound(time.begin(), time.end(), Tab().ViewMax() - timeShift) - time.begin());
if (end < count)
{
++end;
@@ -3199,19 +3138,24 @@ namespace jdi
}
const std::size_t count = std::min(file->time.size(), signal->values.size());
const SignalStyle* style = m_dataSet->Style(ref);
// 时间偏移:可见采样对应的源时刻区间要整体左移(画在 t + shift 处)。
const double timeShift = (style != nullptr) ? style->offsetTime : 0.0;
const double sourceMin = viewMin - timeShift;
const double sourceMax = viewMax - timeShift;
std::size_t begin = 0;
std::size_t end = count;
if (file->timeMonotonic)
{
// 时间单调时直接二分定位区间,百万级采样也只需扫过可见段。
begin = static_cast<std::size_t>(
std::lower_bound(file->time.begin(), file->time.end(), viewMin) - file->time.begin());
std::lower_bound(file->time.begin(), file->time.end(), sourceMin) - file->time.begin());
if (begin > 0)
{
--begin;
}
end = static_cast<std::size_t>(
std::lower_bound(file->time.begin(), file->time.end(), viewMax) - file->time.begin());
std::lower_bound(file->time.begin(), file->time.end(), sourceMax) - file->time.begin());
if (end < count)
{
++end;
@@ -3223,12 +3167,22 @@ namespace jdi
if (!file->timeMonotonic)
{
const double t = file->time[index];
if (!std::isfinite(t) || t < viewMin || t > viewMax)
if (!std::isfinite(t) || t < sourceMin || t > sourceMax)
{
continue;
}
}
const double value = signal->values[index];
// ⚠️ 自适应必须基于**画出来的曲线**:套用与绘制完全相同的变换
//(系数 → 绝对值 → 镜像),否则调整系数/镜像后自适应范围会按原始数据算错
//(用户报告"自适应区域错误,看上去还是按照原始数据来的")。
// 水平镜像时画面上的第 index 个位置取源下标 count-1-index 的值。
const std::size_t sourceIndex = SourceIndexFor(*m_dataSet->Style(ref), count, index);
const double raw = signal->values[sourceIndex];
if (!std::isfinite(raw))
{
continue;
}
const double value = ApplyDrawTransform(*m_dataSet->Style(ref), raw);
if (std::isfinite(value))
{
low = std::min(low, value);
@@ -4266,27 +4220,13 @@ namespace jdi
case PlotDragMode::CURSOR_MOVE:
{
// 从时间读数标签上抓起时保留横向抓取偏移,避免一按下光标就跳到鼠标中心。
double target = XToTime(fx - static_cast<float>(m_cursorGrabOffset));
double snapped = target;
if (SnapTimeToSample(target, snapped))
{
target = snapped;
}
const double target = XToTime(fx - static_cast<float>(m_cursorGrabOffset));
const bool second =
(m_hoverCursor == 2 && Tab().CursorMode() == PlotCursorMode::DOUBLE);
if (second)
{
Tab().CursorTimeAlt() = target;
}
else
{
Tab().CursorTime() = target;
}
// 抓取读数标签时可能带有一个较大的横向抓取偏移(放大到一定程度后该偏移
// 换算成的时间可能落到视界之外)——拖拽结束前必须把光标夹回当前可见区间,
// 否则用户会看到光标线"跑到视界外"而无法继续操作。
ClampCursorToView(second ? 2 : 1);
// 吸附 + 夹取统一在 `PlaceCursorTime` 里完成(光标位置不变量只在那里维护):
// * 吸附只接受可见区间内、且视界内还能分辨的采样点;
// * 结果必定落在当前可见区间内(否则用户看到的是一拖就"飞到视界边缘")。
PlaceCursorTime(second ? 2 : 1, target);
m_dragMoved = true;
m_cursorHover = true;
return true;
@@ -4654,22 +4594,17 @@ namespace jdi
}
}
// 靠近时间光标线时优先进入拖动,避免误触发平移或缩放选区。
{
int cursorWhich = 0;
if (IsOnCursorLine(x, cursorWhich) && UiPointInRect(m_area, fx, fy))
{
m_dragMode = PlotDragMode::CURSOR_MOVE;
m_hoverCursor = cursorWhich;
// 点中的光标成为“活动光标”:它触发的内容将画在上层。
m_activeCursor = cursorWhich;
m_cursorHover = true;
return true;
}
}
// 图例与 GX 编号徽标叠在波形之上、且直接位于网格顶部——用户明确要求它们**优先响应**:
// 必须先于「时间光标线 / 光标读数 / 边沿计数横向光标 / 选区放大」判定,否则:
// * 光标线恰好经过徽标/图例时,按下会变成拖光标;
// * 选区放大模式下会变成“开始框选”,用户看到的就是“GX 拖不动、图例点不中”。
// 层序上仍然让位于浮动面板(面板绘制在徽标与图例之上),因此先挡一次面板命中。
int guardLane = -1;
int guardKind = 0;
const bool guardPanel = TopPanelAtPoint(fx, fy, guardLane, guardKind);
// 网格次序拖拽:按在图例的 GX 编号徽标上即可整块拎起该网格上下移动。
// 位置在浮动面板之后(面板压在波形之上,应优先命中),但在信号拖拽之前。
if (!guardPanel)
{
const int badgeLane = LaneBadgeAtPoint(x, y);
if (badgeLane >= 0)
@@ -4686,6 +4621,36 @@ namespace jdi
}
}
// 点击图例条目等价于在左侧列表中选中该信号(样式区随之切换目标)。
// **必须在选区放大判定之前**:选区放大模式只改变拖拽语义,
// 不应影响图例的悬停与点选(否则用户在选区放大模式下无法切换信号)。
if (!guardPanel)
{
const SignalRef legendHit = LegendAtPoint(x, y);
if (legendHit.IsValid())
{
SelectSignal(legendHit);
m_dragMode = PlotDragMode::NONE;
return true;
}
}
// 靠近时间光标线时进入拖动。
// (放在徽标/图例之后:两者重叠时徽标与图例优先,与用户要求一致。)
if (!guardPanel)
{
int cursorWhich = 0;
if (IsOnCursorLine(x, cursorWhich) && UiPointInRect(m_area, fx, fy))
{
m_dragMode = PlotDragMode::CURSOR_MOVE;
m_hoverCursor = cursorWhich;
// 点中的光标成为“活动光标”:它触发的内容将画在上层。
m_activeCursor = cursorWhich;
m_cursorHover = true;
return true;
}
}
// 实验性功能浮动面板(面板压在波形之上,必须先于信号拖拽判定):
// * ✕ → 关闭该网格对应的功能;
// * 逐条目参数输入框 → 进入内联编辑;
@@ -4976,19 +4941,6 @@ namespace jdi
return false;
}
// 点击图例条目等价于在左侧列表中选中该信号(样式区随之切换目标)。
// **必须在选区放大判定之前**:选区放大模式只改变拖拽语义,
// 不应影响图例的悬停与点选(否则用户在选区放大模式下无法切换信号)。
{
const SignalRef legendHit = LegendAtPoint(x, y);
if (legendHit.IsValid())
{
SelectSignal(legendHit);
m_dragMode = PlotDragMode::NONE;
return true;
}
}
const PlotZoomMode mode = Tab().ZoomMode();
if (mode == PlotZoomMode::SELECT)
{
@@ -5330,6 +5282,13 @@ namespace jdi
const std::size_t sampleCount = std::min(resolved.file->time.size(), resolved.data->values.size());
const double viewMin = Tab().ViewMin();
const double viewMax = Tab().ViewMax();
// 绘制前的数值变换(系数/绝对值/镜像/偏移)会改变顶点,必须进缓存键。
const float gain = (resolved.style != nullptr) ? resolved.style->gain : 1.0f;
const bool absolute = (resolved.style != nullptr) && resolved.style->absolute;
const bool mirrorVertical = (resolved.style != nullptr) && resolved.style->mirrorVertical;
const bool mirrorHorizontal = (resolved.style != nullptr) && resolved.style->mirrorHorizontal;
const float offsetY = (resolved.style != nullptr) ? resolved.style->offsetY : 0.0f;
const double offsetTime = (resolved.style != nullptr) ? resolved.style->offsetTime : 0.0;
for (const PolylineCacheEntry& entry : m_polylineCache)
{
@@ -5337,7 +5296,9 @@ namespace jdi
entry.viewMin == viewMin && entry.viewMax == viewMax && entry.plotX == m_plotRect.X &&
entry.plotWidth == m_plotRect.Width && entry.laneY == lane.rect.Y &&
entry.laneHeight == lane.rect.Height && entry.laneYMin == lane.yMin &&
entry.laneYMax == lane.yMax)
entry.laneYMax == lane.yMax && entry.gain == gain && entry.absolute == absolute &&
entry.mirrorVertical == mirrorVertical && entry.mirrorHorizontal == mirrorHorizontal &&
entry.offsetY == offsetY && entry.offsetTime == offsetTime)
{
return &entry;
}
@@ -5362,6 +5323,12 @@ namespace jdi
entry.laneHeight = lane.rect.Height;
entry.laneYMin = lane.yMin;
entry.laneYMax = lane.yMax;
entry.gain = gain;
entry.absolute = absolute;
entry.mirrorVertical = mirrorVertical;
entry.mirrorHorizontal = mirrorHorizontal;
entry.offsetY = offsetY;
entry.offsetTime = offsetTime;
BuildPolyline(resolved, lane, entry.points, entry.breaks, entry.band, entry.bandBreaks,
entry.bandMode, entry.bandSpiky);
@@ -5397,18 +5364,24 @@ namespace jdi
const double viewMin = Tab().ViewMin();
const double viewMax = Tab().ViewMax();
// 时间偏移:采样画在 `t + offsetTime` 处,因此“落在当前视界内的采样”
// 其源时刻位于 `[viewMin - offsetTime, viewMax - offsetTime]`。
const double timeShift = resolved.style->offsetTime;
const double sourceMin = viewMin - timeShift;
const double sourceMax = viewMax - timeShift;
std::size_t begin = 0;
std::size_t end = count;
if (resolved.file->timeMonotonic)
{
const std::vector<double>::const_iterator first = std::lower_bound(time.begin(), time.end(), viewMin);
const std::vector<double>::const_iterator first = std::lower_bound(time.begin(), time.end(), sourceMin);
begin = static_cast<std::size_t>(first - time.begin());
if (begin > 0)
{
--begin;
}
const std::vector<double>::const_iterator last = std::lower_bound(time.begin(), time.end(), viewMax);
const std::vector<double>::const_iterator last = std::lower_bound(time.begin(), time.end(), sourceMax);
end = static_cast<std::size_t>(last - time.begin());
if (end < count)
{
@@ -5462,8 +5435,9 @@ namespace jdi
{
const float marginPx =
std::max(4.0f, std::max(1.0f, resolved.style->lineWidth) * m_scale + 3.0f);
const double ta = XToTime(m_stripX0 - marginPx);
const double tb = XToTime(m_stripX1 + marginPx);
// 条带边界是**绘制时刻**,换算成源时刻要减去时间偏移。
const double ta = XToTime(m_stripX0 - marginPx) - timeShift;
const double tb = XToTime(m_stripX1 + marginPx) - timeShift;
const double clipLo = std::min(ta, tb);
const double clipHi = std::max(ta, tb);
std::size_t sBegin = static_cast<std::size_t>(
@@ -5511,8 +5485,16 @@ namespace jdi
const std::vector<double>& coarseMin = resolved.data->coarseMin;
const std::vector<double>& coarseMax = resolved.data->coarseMax;
const std::size_t coarseStride = resolved.data->coarseStride;
// ⚠️ 粗粒度分组只存了逐组的 min/max:取绝对值 / 垂直镜像会把两者互换,
// 此时分组层无法再给出正确的包络,必须回退到逐采样点变换(见 `DrawTransformOrderPreserving`)。
// Y 偏移是保序的(整体加常数),因此仍可用分组层 —— 只需把偏移一并加上。
const bool hasCoarse =
(coarseStride > 0) && !coarseMin.empty() && (coarseMin.size() == coarseMax.size());
(coarseStride > 0) && !coarseMin.empty() && (coarseMin.size() == coarseMax.size()) &&
DrawTransformOrderPreserving(*resolved.style);
// 保序变换(正系数 + Y 偏移)下:分组极值 × 系数 + 偏移 即为变换后的极值
//(系数 1、偏移 0 时逐位不变)。
const double coarseScale = static_cast<double>(resolved.style->gain);
const double coarseOffset = static_cast<double>(resolved.style->offsetY);
bool inSegment = false;
@@ -5535,21 +5517,24 @@ namespace jdi
std::size_t searchFrom = begin;
for (std::size_t column = 0; column < columns; ++column)
{
// `t0/t1` 是**绘制时刻**的列边界;源时刻要减去时间偏移(偏移为 0 时二者相同)。
const double t0 = viewMin + viewSpan * (static_cast<double>(column) / static_cast<double>(columns));
const double t1 =
viewMin + viewSpan * (static_cast<double>(column + 1) / static_cast<double>(columns));
const double s0 = t0 - timeShift;
const double s1 = t1 - timeShift;
// 列边界随 column 单调递增,从上一列的位置继续二分,
// 把每次搜索的范围压到剩余区间,避免 2400 次全区间二分带来的缓存缺失。
const std::size_t i0 = static_cast<std::size_t>(
std::lower_bound(time.begin() + static_cast<std::ptrdiff_t>(searchFrom), rangeEnd, t0) -
std::lower_bound(time.begin() + static_cast<std::ptrdiff_t>(searchFrom), rangeEnd, s0) -
time.begin());
searchFrom = i0;
const std::size_t i1 = (column + 1 == columns)
? end
: static_cast<std::size_t>(
std::lower_bound(time.begin() + static_cast<std::ptrdiff_t>(i0),
rangeEnd, t1) -
rangeEnd, s1) -
time.begin());
double low = std::numeric_limits<double>::infinity();
@@ -5559,15 +5544,20 @@ namespace jdi
bool broken = false;
// (一)落在本列区间内的采样点。
// 水平镜像时取的是**源下标**(区间同样反转),因此先把列区间映射到源下标区间。
const std::size_t srcA = std::min(SourceIndexFor(*resolved.style, count, i0),
SourceIndexFor(*resolved.style, count, (i1 > 0) ? (i1 - 1) : 0));
const std::size_t srcB = std::max(SourceIndexFor(*resolved.style, count, i0),
SourceIndexFor(*resolved.style, count, (i1 > 0) ? (i1 - 1) : 0));
if (i1 > i0 && hasCoarse && (i1 - i0) >= 2 * coarseStride)
{
const std::size_t firstBucket = i0 / coarseStride;
const std::size_t firstBucket = srcA / coarseStride;
const std::size_t lastBucket =
std::min((i1 - 1) / coarseStride, coarseMin.size() - 1);
std::min(srcB / coarseStride, coarseMin.size() - 1);
for (std::size_t bucket = firstBucket; bucket <= lastBucket; ++bucket)
{
const double bucketLow = coarseMin[bucket];
const double bucketHigh = coarseMax[bucket];
const double bucketLow = coarseMin[bucket] * coarseScale + coarseOffset;
const double bucketHigh = coarseMax[bucket] * coarseScale + coarseOffset;
if (!std::isfinite(bucketLow))
{
// 该分组全部是无效值:包络到此断开,后续另起一段。
@@ -5588,9 +5578,9 @@ namespace jdi
}
else if (i1 > i0)
{
for (std::size_t index = i0; index < i1; ++index)
for (std::size_t index = srcA; index <= srcB; ++index)
{
const double value = values[index];
const double value = ApplyDrawTransform(*resolved.style, values[index]);
if (!std::isfinite(value))
{
// 该列含无效值:包络绘制到本列结束,后续另起一段。
@@ -5622,30 +5612,33 @@ namespace jdi
// 其极值必然出现在采样点或列边界上,因此「列内采样点 + 两侧边界取值」就是
// 该列包络的精确解,且相邻两列在边界处取到同一个值,包络带严丝合缝。
double boundary = 0.0;
if (PolylineValueAt(time, values, count, i0, t0, boundary))
if (PolylineValueAt(time, values, count, *resolved.style, i0, s0, boundary))
{
boundary = ApplyDrawTransform(*resolved.style, boundary);
if (boundary < low)
{
low = boundary;
lowOrder = (i0 > 0) ? (i0 - 1) : 0;
lowOrder = (i0 > 0) ? SourceIndexFor(*resolved.style, count, i0 - 1) : 0;
}
if (boundary > high)
{
high = boundary;
highOrder = (i0 > 0) ? (i0 - 1) : 0;
highOrder = (i0 > 0) ? SourceIndexFor(*resolved.style, count, i0 - 1) : 0;
}
}
if (PolylineValueAt(time, values, count, i1, t1, boundary))
if (PolylineValueAt(time, values, count, *resolved.style, i1, s1, boundary))
{
boundary = ApplyDrawTransform(*resolved.style, boundary);
const std::size_t order = SourceIndexFor(*resolved.style, count, (i1 > 0) ? (i1 - 1) : 0);
if (boundary < low)
{
low = boundary;
lowOrder = i1;
lowOrder = order;
}
if (boundary > high)
{
high = boundary;
highOrder = i1;
highOrder = order;
}
}
@@ -5722,14 +5715,17 @@ namespace jdi
for (std::size_t index = begin; index < end; index += stride)
{
const double t = time[index];
const double value = values[index];
// 水平镜像 = 数据反序:画面上第 index 个位置取源下标 count-1-index 的值。
const double value = ApplyDrawTransform(
*resolved.style, values[SourceIndexFor(*resolved.style, count, index)]);
if (!std::isfinite(t) || !std::isfinite(value))
{
inSegment = false;
continue;
}
const float x = TimeToX(t);
// 时间偏移:该采样画在 `t + offsetTime` 处(例如 t=1s、偏移 -0.5s → 画在 0.5s)。
const float x = TimeToX(t + timeShift);
float y = ValueToY(value, lane);
y = static_cast<float>(ClampDouble(y, limitTop, limitBottom));
@@ -6386,6 +6382,14 @@ namespace jdi
mixFloat(resolved.style->opacity);
mixFloat(resolved.style->lineWidth);
mix(static_cast<std::uint64_t>(resolved.style->mode));
// 绘制前的数值变换(系数 / 绝对值 / 镜像)会改变波形形状。
mixFloat(resolved.style->gain);
mix(resolved.style->absolute ? 1ull : 0ull);
mix(resolved.style->mirrorVertical ? 1ull : 0ull);
mix(resolved.style->mirrorHorizontal ? 1ull : 0ull);
// 偏移(Y 平移 + 时间平移)同样会改变波形。
mixFloat(resolved.style->offsetY);
mixFloat(static_cast<float>(resolved.style->offsetTime));
}
// 图例文案参与绘制,重命名必须让缓存失效。
mixText(resolved.data->Title());
@@ -6431,6 +6435,12 @@ namespace jdi
entry.opacity = resolved.style->opacity;
entry.lineWidth = resolved.style->lineWidth;
entry.mode = static_cast<int>(resolved.style->mode);
entry.gain = resolved.style->gain;
entry.absolute = resolved.style->absolute;
entry.mirrorVertical = resolved.style->mirrorVertical;
entry.mirrorHorizontal = resolved.style->mirrorHorizontal;
entry.offsetY = resolved.style->offsetY;
entry.offsetTime = resolved.style->offsetTime;
}
lane.push_back(entry);
}
@@ -7289,6 +7299,14 @@ namespace jdi
mixFloat(resolved.style->opacity);
mixFloat(resolved.style->lineWidth);
mix(static_cast<std::uint64_t>(resolved.style->mode));
// 「调整」变换会改变图层里的波形,也必须让图层失效
//(否则贴回的旧像素会与当前样式不一致)。
mixFloat(resolved.style->gain);
mix(resolved.style->absolute ? 1ull : 0ull);
mix(resolved.style->mirrorVertical ? 1ull : 0ull);
mix(resolved.style->mirrorHorizontal ? 1ull : 0ull);
mixFloat(resolved.style->offsetY);
mixFloat(static_cast<float>(resolved.style->offsetTime));
}
return hash;
}
@@ -8851,7 +8869,7 @@ namespace jdi
int visibleCount = 0;
int drawnCount = 0;
// 先测量图例总宽度,再用半透明底板掠开底下的网格线,提升可读性。
// 先测量图例总宽度:只用来确定装饰缓存矩形(不再绘制任何托底/底板)。
float legendWidth = 0.0f;
for (const SignalRef& ref : laneSignals)
{
@@ -8877,23 +8895,24 @@ namespace jdi
legendWidth += itemWidth;
}
{
// 底板从编号徽标开始一直盖到最后一个图例条目(编号徽标一并被掠开底下的网格线)。
// 底板范围(编号徽标 + 图例条目):这里**只记录装饰矩形**用于缓存贴回。
const float plateRight = cursorX + legendWidth + (legendWidth > 0.0f ? 2.0f * m_scale : 0.0f);
// 记录图例装饰矩形(含编号徽标):从徽标左缘到图例右缘。
{
const float left = std::min(badgeRect.X, legendBand.X - 4.0f * m_scale) - 2.0f * m_scale;
const float right = std::max(badgeRect.X + badgeRect.Width, plateRight) + 2.0f * m_scale;
m_decorRects.push_back(gd::RectF(left, top - 4.0f * m_scale, right - left,
height + 8.0f * m_scale));
}
// 命中装饰缓存:整块图例(底板 + 徽标 + 色块 + 文字)改由装饰层贴回,跳过重绘。
// ⚠️ **不再绘制不透明托底**(用户要求去掉):原来的底板会把徽标与图例下方的
// 网格线整块擦掉,使这一条看起来像一个"可点击的控件",而它右侧的空白区域
// 实际并不响应点击(选区放大模式下会变成开始框选)。现在图例与徽标直接叠在
// 网格线之上,命中范围与看到的控件一一对应。
//
// 命中装饰缓存:整块图例(徽标 + 色块 + 文字)改由装饰层贴回,跳过重绘。
if (m_useDecorCache)
{
return;
}
UiFillRoundRect(g, gd::RectF(legendBand.X - 4.0f * m_scale, top - 2.0f * m_scale,
plateRight - legendBand.X + 4.0f * m_scale, height + 4.0f * m_scale),
3.0f * m_scale, m_theme->cardBg | 0xFF000000u);
}
// 编号徽标绘制在底板之上(与信号面板的 GX 徽标使用同一套配色与圆角)。
@@ -9362,10 +9381,13 @@ namespace jdi
const bool interpolate = (resolved.style->mode != DrawMode::SCATTER);
double value = 0.0;
if (!SampleSignalAt(*resolved.file, *resolved.data, time, interpolate, value))
if (!SampleDisplayedSignalAt(*resolved.file, *resolved.data, *resolved.style, time,
interpolate, value))
{
continue;
}
// 取值读数必须与画出来的波形一致:同样套用系数/绝对值/镜像。
value = ApplyDrawTransform(*resolved.style, value);
const float rawY = ValueToY(value, lane);
// 上下越界的数据点不再被丢弃:与左右越界一致,贴边绘制指示方向的三角。
@@ -9386,8 +9408,10 @@ namespace jdi
if (showDelta)
{
double otherValue = 0.0;
if (SampleSignalAt(*resolved.file, *resolved.data, otherTime, interpolate, otherValue))
if (SampleDisplayedSignalAt(*resolved.file, *resolved.data, *resolved.style, otherTime,
interpolate, otherValue))
{
otherValue = ApplyDrawTransform(*resolved.style, otherValue);
const double delta = value - otherValue;
// 增量 = 右侧光标取值 − 左侧光标取值,包在圆括号里并按差值显式给出正负号。
text += L" (";
@@ -10266,7 +10290,7 @@ namespace jdi
std::size_t begin = 0;
std::size_t end = 0;
VisibleIndexRange(*resolved.file, count, begin, end);
VisibleIndexRange(*resolved.file, count, begin, end, resolved.style->offsetTime);
if (end <= begin)
{
return;
@@ -10307,20 +10331,23 @@ namespace jdi
for (std::size_t index = begin; index < end; index += stride)
{
if (index < curveBegin || (index - curveBegin) >= curve.size())
// 水平镜像 = 数据反序:遍历下标映射到源下标后再取曲线值。
const std::size_t sourceIndex = SourceIndexFor(*resolved.style, count, index);
if (sourceIndex < curveBegin || (sourceIndex - curveBegin) >= curve.size())
{
inSegment = false;
continue;
}
const float sampled = curve[index - curveBegin];
const float sampled = curve[sourceIndex - curveBegin];
if (!std::isfinite(sampled))
{
inSegment = false;
continue;
}
float y = ValueToY(static_cast<double>(sampled), lane);
float y = ValueToY(ApplyDrawTransform(*resolved.style, static_cast<double>(sampled)), lane);
y = static_cast<float>(ClampDouble(y, limitTop, limitBottom));
const float x = TimeToX(time[index]);
// 时间偏移:该采样画在 `t + offsetTime` 处。
const float x = TimeToX(time[index] + resolved.style->offsetTime);
if (!inSegment)
{
breaks.push_back(points.size());
+47 -3
View File
@@ -34,7 +34,14 @@ namespace jdi
CMD_COLOR_BASE = 100, /**< 预设颜色基准(配合色块下标)。 */
CMD_MODE_BASE = 200, /**< 绘制样式基准(配合下标)。 */
CMD_LANE_BASE = 300, /**< 网格归属基准(配合下标)。 */
CMD_LANE_NEW = 400 /**< 新建网格。 */
CMD_LANE_NEW = 400, /**< 新建网格。 */
CMD_GAIN_EDIT = 500, /**< 「系数」二级面板里的系数输入框(幂等提交)。 */
CMD_GAIN_RESET = 501, /**< 「系数调整」标题栏右侧的还原按钮:把系数重置为 1。 */
CMD_OFFSET_RESET = 502, /**< 「偏移调整」标题栏右侧的还原按钮:两个偏移都恢复为默认值。 */
CMD_OFFSET_Y = 503, /**< 「偏移调整」:Y 轴偏移输入框。 */
CMD_OFFSET_TIME = 504, /**< 「偏移调整」:时间轴偏移输入框。 */
CMD_MIRROR_HORIZONTAL = 510, /**< 「镜像」二级面板:水平镜像(数据反序)。 */
CMD_MIRROR_VERTICAL = 511 /**< 「镜像」二级面板:垂直镜像(按 Y=0 上下翻转)。 */
};
/**
@@ -339,6 +346,9 @@ namespace jdi
OPACITY_VALUE, /**< 不透明度数值框(可点击输入)。 */
WIDTH_SLIDER, /**< 线宽滑块。 */
WIDTH_VALUE, /**< 线宽数值框(可点击输入)。 */
GAIN_ADJUST, /**< 「系数」按钮(点击展开系数二级面板)。 */
MIRROR_ADJUST, /**< 「镜像」按钮(点击展开镜像二级面板)。 */
ABSOLUTE_TOGGLE, /**< 「绝对值」切换按钮。 */
TOGGLE_ALL, /**< 全部显示/隐藏按钮。 */
FILTER_CLEAR /**< 「筛选信号」输入框右侧的清空 ✕。 */
};
@@ -489,6 +499,35 @@ namespace jdi
*/
bool OpenColorPickerPopup(const SignalStyle& style);
/**
* @brief 打开「系数」的**二级面板**(一个整行输入框 + 标题栏右侧的还原按钮)。
*
* 与自定义颜色入口同一套交互:点击按钮 → 抬起时展开浮动面板 → 面板内的输入框可编辑
* 绘制前乘上的系数(默认 1,至少显示两位小数),回车 / 点击其它位置提交,面板保持打开
*(与网格 Y 轴上下限的菜单内联输入框一致)。“还原”图标按钮把系数重置为 1。
*
* @return 面板已打开返回 true。
*/
bool OpenGainMenu();
/**
* @brief 打开「镜像」的**二级面板**(两行带复选框的选项:水平镜像 / 垂直镜像)。
*
* 两项都是“切换项”,点击后勾选状态立即生效且**菜单保持打开**,方便连续勾选;
* 只要任一项被勾选,「镜像」按钮即显示为激活态。
*
* @return 面板已打开返回 true。
*/
bool OpenMirrorMenu();
/**
* @brief 清除样式区的悬停高亮。
*
* 二级面板(弹出菜单)一打开就会接管鼠标事件,样式区不再收到 `WM_MOUSEMOVE`,
* 因此必须在打开面板时主动清掉悬停态,否则该按钮会一直保持"鼠标按住"般的高亮。
*/
void ClearStyleHover();
/**
* @brief 判断当前标签页中全部信号是否均为可见状态。
* @return 全部可见且至少有一条信号返回 true。
@@ -592,13 +631,16 @@ namespace jdi
std::array<gd::RectF, 3> m_modeRects; /**< 绘制样式按钮矩形。 */
// 样式区固定四行:颜色 / 样式 / 透明度 / 线宽。各行控件共用同一内容列宽度与左边界。
std::array<gd::RectF, 4> m_styleRowRects; /**< 四行整体矩形(含标签列)。 */
// 样式区固定五行:颜色 / 样式 / 调整(系数调整·取绝对值·中心反转)/ 线宽 / 透明度。
// 各行控件共用同一内容列宽度与左边界。
std::array<gd::RectF, 5> m_styleRowRects; /**< 五行整体矩形(含标签列)。 */
gd::RectF m_opacityTrackRect; /**< 不透明度滑块轨道矩形。 */
gd::RectF m_opacityValueRect; /**< 不透明度数值文本矩形。 */
gd::RectF m_widthTrackRect; /**< 线宽滑块轨道矩形。 */
gd::RectF m_widthValueRect; /**< 线宽数值文本矩形(双击可输入)。 */
/** 「调整」行的三个等宽按钮:0 = 系数调整,1 = 取绝对值,2 = 中心反转。 */
std::array<gd::RectF, 3> m_adjustRects;
std::vector<ListRow> m_rows; /**< 列表行集合(惰性缓存)。 */
bool m_rowsDirty = true; /**< 列表行集合是否需要重建。 */
@@ -640,6 +682,8 @@ namespace jdi
bool m_pendingLaneMenu = false; /**< 左键抬起时是否弹出网格归属菜单。 */
SignalRef m_pendingLaneRef; /**< 待弹出菜单的目标信号。 */
bool m_pendingColorPicker = false; /**< 左键抬起时是否弹出拾色器。 */
bool m_pendingGainMenu = false; /**< 左键抬起时是否弹出「系数」的二级面板。 */
bool m_pendingMirrorMenu = false; /**< 左键抬起时是否弹出「镜像」的二级面板。 */
/**
* @brief 待确认的「关闭类」按钮(✕)。
+447 -28
View File
@@ -88,15 +88,15 @@ namespace jdi
return p == pattern.size();
}
// 样式编辑区:固定四行网格(颜色 / 样式 / 透明度 / 线宽),行高、间距与内容列宽度统一,
// 保证面板宽度变化时四行控件始终对齐,且不会被无限制拉伸。
// 样式编辑区:固定五行网格(颜色 / 样式 / 调整 / 线宽 / 透明度),
// 行高、间距与内容列宽度统一,保证面板宽度变化时各行控件始终对齐,且不会被无限制拉伸。
const float kStylePadH = 12.0f; /**< 样式区左右内边距。 */
const float kStylePadV = 12.0f; /**< 样式区上下内边距。 */
const float kStyleRowHeight = 26.0f; /**< 样式区单行高度。 */
const float kStyleRowGap = 10.0f; /**< 样式区行间距。 */
const float kStyleLabelWidth = 44.0f; /**< 样式行标签列宽度。 */
const float kStyleLabelGap = 8.0f; /**< 标签列与内容列间距。 */
const float kStyleRowCount = 4.0f; /**< 样式区固定行数。 */
const float kStyleRowCount = 5.0f; /**< 样式区固定行数。 */
const float kStyleContentMax = 260.0f; /**< 内容列最大宽度(再拉宽面板也不再延伸)。 */
const float kSwatchEdge = 18.0f; /**< 颜色色块边长上限(含自定义颜色入口)。 */
const float kSwatchMin = 11.0f; /**< 颜色色块边长下限。 */
@@ -105,10 +105,26 @@ namespace jdi
const float kSliderValueWidth = 52.0f; /**< 滑块行数值文本宽度(透明度与线宽一致)。 */
const float kSliderValueGap = 8.0f; /**< 滑块轨道与数值文本间距。 */
const float kTrackHeight = 6.0f; /**< 滑块轨道高度。 */
const float kAdjustGap = 6.0f; /**< 「调整」行三个按钮之间的间距。 */
/** 「调整」行的按钮下标(与 `m_adjustRects` 同序)。 */
const int kAdjustGain = 0; /**< 系数(点击展开系数二级面板)。 */
const int kAdjustMirror = 1; /**< 镜像(点击展开镜像二级面板)。 */
const int kAdjustAbsolute = 2; /**< 绝对值。 */
const int kAdjustCount = 3; /**< 按钮数量。 */
const float kWidthMin = 0.5f; /**< 允许的最小线宽(px)。 */
const float kWidthMax = 10.0f; /**< 允许的最大线宽(px)。 */
/** @brief 系数调整允许的绝对值上限(防止输入极端值把波形完全挤出可视区)。 */
const double kGainLimit = 10000.0;
/** @brief 系数为默认值 1(不做任何缩放)时按钮显示为未启用样式。 */
bool GainIsDefault(float gain)
{
return std::fabs(gain - 1.0f) < 1.0e-6f;
}
/** @brief 可选绘制样式顺序(不含阶梯方式)。 */
const DrawMode kModeOptions[] = {DrawMode::LINE, DrawMode::SCATTER, DrawMode::LINE_SCATTER};
const int kModeOptionCount = static_cast<int>(sizeof(kModeOptions) / sizeof(kModeOptions[0]));
@@ -149,12 +165,59 @@ namespace jdi
{
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 显示文本。
*/
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 带正负符号的数值文本:正数也带 `+`(非编辑态显示偏移量时使用)。
* @param [IN] value 数值。
* @return 形如 `+1.50` / `-0.25` / `+0.00` 的文本。
*/
std::wstring FormatSignedNumber(double value)
{
const std::wstring text = FormatNumberText(std::fabs(value));
return ((value < 0.0) ? L"-" : L"+") + text;
}
/**
* @brief 把系数格式化为界面文本(与偏移量同一套两位小数规则)。
* @param [IN] gain 系数。
* @return 显示文本。
*/
std::wstring FormatGainText(float gain)
{
return FormatNumberText(static_cast<double>(gain));
}
}
SignalPanel::SignalPanel()
{
m_colorRects.fill(gd::RectF());
m_modeRects.fill(gd::RectF());
m_adjustRects.fill(gd::RectF());
}
void SignalPanel::Setup(HWND host, const UiFonts* fonts, const Theme* theme, PopupMenu* menu,
@@ -181,6 +244,8 @@ namespace jdi
m_editField = StyleEditField::NONE;
m_pendingLaneMenu = false;
m_pendingColorPicker = false;
m_pendingGainMenu = false;
m_pendingMirrorMenu = false;
m_pendingClosePart = RowPart::NONE;
m_pendingCloseRow = -1;
m_pendingFilterClear = false;
@@ -257,6 +322,17 @@ namespace jdi
{
return LoadCursorW(nullptr, IDC_HAND);
}
// 样式区的可点击控件(「调整」行三个按钮、线宽/透明度数值框)也用手型光标。
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
if (UiPointInRect(m_adjustRects[kAdjustGain], fx, fy) ||
UiPointInRect(m_adjustRects[kAdjustMirror], fx, fy) ||
UiPointInRect(m_adjustRects[kAdjustAbsolute], fx, fy) || UiPointInRect(m_widthValueRect, fx, fy) ||
UiPointInRect(m_opacityValueRect, fx, fy))
{
return LoadCursorW(nullptr, IDC_HAND);
}
return nullptr;
}
@@ -617,7 +693,7 @@ namespace jdi
const float rowStep = (kStyleRowHeight + kStyleRowGap) * m_scale;
const float firstTop = m_styleRect.Y + kStylePadV * m_scale;
// 内容列宽度带上下限:面板变窄时收缩,面板变宽时不再延伸,四行始终共用同一宽度。
// 内容列宽度带上下限:面板变窄时收缩,面板变宽时不再延伸,五行始终共用同一宽度。
const float available = std::max(40.0f, m_styleRect.Width - padH * 2.0f - labelWidth - labelGap);
const float contentWidth = std::min(available, kStyleContentMax * m_scale);
@@ -634,8 +710,9 @@ namespace jdi
const gd::RectF& colorRow = m_styleRowRects[0];
const gd::RectF& modeRow = m_styleRowRects[1];
const gd::RectF& opacityRow = m_styleRowRects[2];
const gd::RectF& adjustRow = m_styleRowRects[2];
const gd::RectF& widthRow = m_styleRowRects[3];
const gd::RectF& opacityRow = m_styleRowRects[4];
// ---- 颜色行:预设色块 + 同尺寸自定义颜色入口(合计恰好铺满内容列宽度)----
int paletteCount = 0;
@@ -663,7 +740,7 @@ namespace jdi
}
m_colorCount = std::max(1, count);
// 颜色行与其余三行共用同一左边界(不再居中),保证四行纵向对齐。
// 颜色行与其余各行共用同一左边界(不再居中),保证五行纵向对齐。
const float colorRowLeft = contentLeft;
const float swatchTop = colorRow.Y + (rowHeight - swatchSize) * 0.5f;
@@ -689,20 +766,32 @@ namespace jdi
modeWidth, rowHeight);
}
// ---- 透明度行与线宽行:几何完全一致,保证纵向宽度对齐 ----
// ---- 「调整」行:系数调整 / 取绝对值 / 中心反转(三个铺满内容列的等宽按钮)----
const float adjustGap = kAdjustGap * m_scale;
const float adjustWidth =
std::max(20.0f, (contentWidth - adjustGap * static_cast<float>(kAdjustCount - 1)) /
static_cast<float>(kAdjustCount));
for (int index = 0; index < kAdjustCount; ++index)
{
m_adjustRects[static_cast<std::size_t>(index)] =
gd::RectF(contentLeft + static_cast<float>(index) * (adjustWidth + adjustGap), adjustRow.Y,
adjustWidth, rowHeight);
}
// ---- 线宽行与透明度行:几何完全一致,保证纵向宽度对齐(线宽在上、透明度在下)----
const float valueWidth = std::min(kSliderValueWidth * m_scale, contentWidth * 0.4f);
const float trackWidth = std::max(24.0f, contentWidth - valueWidth - kSliderValueGap * m_scale);
const float trackHeight = kTrackHeight * m_scale;
m_opacityTrackRect = gd::RectF(contentLeft, opacityRow.Y + (rowHeight - trackHeight) * 0.5f,
trackWidth, trackHeight);
m_opacityValueRect = gd::RectF(contentLeft + trackWidth + kSliderValueGap * m_scale, opacityRow.Y,
valueWidth, rowHeight);
m_widthTrackRect =
gd::RectF(contentLeft, widthRow.Y + (rowHeight - trackHeight) * 0.5f, trackWidth, trackHeight);
m_widthValueRect = gd::RectF(contentLeft + trackWidth + kSliderValueGap * m_scale, widthRow.Y,
valueWidth, rowHeight);
m_opacityTrackRect = gd::RectF(contentLeft, opacityRow.Y + (rowHeight - trackHeight) * 0.5f,
trackWidth, trackHeight);
m_opacityValueRect = gd::RectF(contentLeft + trackWidth + kSliderValueGap * m_scale, opacityRow.Y,
valueWidth, rowHeight);
}
void SignalPanel::Layout(const gd::RectF& area)
@@ -1207,27 +1296,31 @@ namespace jdi
}
}
// ---- 第三行:透明度(数值可点击输入)----
// ---- 第三行:调整(系数 / 镜像 / 绝对值,三个切换类按钮)----
// 结算顺序固定为「系数 → 绝对值 → 镜像」;「系数」「镜像」点击后展开二级面板。
{
paintRowLabel(2, L"透明度");
paintRowLabel(2, 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)
const auto paintToggle = [&](int index, const wchar_t* label, bool checked, StyleHit target)
{
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);
}
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"线宽");
@@ -1253,6 +1346,26 @@ namespace jdi
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);
}
}
}
bool SignalPanel::AreAllSignalsVisible() const
@@ -1874,6 +1987,129 @@ namespace jdi
return true;
}
bool SignalPanel::OpenGainMenu()
{
if (m_menu == nullptr || m_tab == nullptr)
{
return false;
}
const SignalStyle* style = StyleOf(SelectedRef());
if (style == nullptr)
{
return false;
}
// 二级面板:两张“标题(带还原图标)+ 输入框”的组合:
// 1. 「系数调整」:一整行系数输入框;
// 2. 「偏移调整」:下方两行分别为「Y轴」与「时间轴」的偏移输入框。
//(与网格 Y 轴上下限的菜单内联编辑同一套做法:提交后面板保持打开,可连续微调。)
std::vector<MenuItem> items;
MenuItem gainHeader;
gainHeader.kind = MenuItemKind::HEADER_ACTION;
gainHeader.label = L"系数调整";
gainHeader.id = CMD_GAIN_RESET; // 点击标题栏右侧的还原图标 → 系数重置为 1
items.push_back(gainHeader);
const std::wstring gainText = FormatGainText(style->gain);
MenuItem gainEdit;
gainEdit.kind = MenuItemKind::EDIT;
gainEdit.label.clear(); // 不显示说明文字:输入框独占整行
gainEdit.id = CMD_GAIN_EDIT;
gainEdit.value = gainText;
gainEdit.editValue = gainText;
gainEdit.filter = EditFilter::NUMBER;
items.push_back(gainEdit);
MenuItem offsetHeader;
offsetHeader.kind = MenuItemKind::HEADER_ACTION;
offsetHeader.label = L"偏移调整";
offsetHeader.id = CMD_OFFSET_RESET; // 点击还原图标 → 两个偏移都恢复默认值
items.push_back(offsetHeader);
// 「Y轴」:对绘制值加上一个常数偏移(波形上下移动),非编辑态带正负号。
MenuItem offsetY;
offsetY.kind = MenuItemKind::EDIT;
offsetY.label = L"Y轴";
offsetY.id = CMD_OFFSET_Y;
offsetY.value = FormatSignedNumber(static_cast<double>(style->offsetY));
offsetY.editValue = FormatNumberText(static_cast<double>(style->offsetY));
offsetY.filter = EditFilter::NUMBER;
items.push_back(offsetY);
// 「时间轴」:把采样沿时间轴平移(画在 t + offsetTime)。
// 非编辑态显示与双光标差值同一套复合单位文本;编辑态显示以秒为单位的数值,
// 并在输入框最右侧提示单位 `s`。
MenuItem offsetTime;
offsetTime.kind = MenuItemKind::EDIT;
offsetTime.label = L"时间轴";
offsetTime.id = CMD_OFFSET_TIME;
offsetTime.value = L"Δt:" + FormatDurationCompound(style->offsetTime);
offsetTime.editValue = FormatNumberText(style->offsetTime);
offsetTime.editSuffix = L"s";
offsetTime.filter = EditFilter::NUMBER;
items.push_back(offsetTime);
// 锚定在按钮下沿:面板紧贴按钮展开(与拾色器"以触发器矩形为锚点"的观感一致)。
const gd::RectF& button = m_adjustRects[kAdjustGain];
const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area;
m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, static_cast<int>(button.X),
static_cast<int>(button.Y + button.Height), items);
// ⚠️ 菜单一旦打开就会接管鼠标事件,样式区不会再收到 WM_MOUSEMOVE →
// 必须在这里主动清掉按钮的悬停态,否则按钮会"像鼠标一直按着"一样持续高亮。
ClearStyleHover();
return true;
}
bool SignalPanel::OpenMirrorMenu()
{
if (m_menu == nullptr || m_tab == nullptr)
{
return false;
}
const SignalStyle* style = StyleOf(SelectedRef());
if (style == nullptr)
{
return false;
}
// 两行带复选框的选项:点击即切换、且**保持菜单打开**(可连续勾选两项)。
std::vector<MenuItem> items;
MenuItem horizontal;
horizontal.kind = MenuItemKind::NORMAL;
horizontal.label = L"水平镜像";
horizontal.id = CMD_MIRROR_HORIZONTAL;
horizontal.checked = style->mirrorHorizontal;
horizontal.keepOpen = true;
items.push_back(horizontal);
MenuItem vertical;
vertical.kind = MenuItemKind::NORMAL;
vertical.label = L"垂直镜像";
vertical.id = CMD_MIRROR_VERTICAL;
vertical.checked = style->mirrorVertical;
vertical.keepOpen = true;
items.push_back(vertical);
const gd::RectF& button = m_adjustRects[kAdjustMirror];
const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area;
m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, static_cast<int>(button.X),
static_cast<int>(button.Y + button.Height), items);
ClearStyleHover();
return true;
}
void SignalPanel::ClearStyleHover()
{
if (m_hoverStyle == StyleHit::NONE && m_hoverStyleIndex < 0)
{
return;
}
m_hoverStyle = StyleHit::NONE;
m_hoverStyleIndex = -1;
}
bool SignalPanel::CloseTransientUi()
{
bool changed = false;
@@ -1895,6 +2131,8 @@ namespace jdi
m_pendingClosePart = RowPart::NONE;
m_pendingCloseRow = -1;
m_pendingFilterClear = false;
m_pendingGainMenu = false;
m_pendingMirrorMenu = false;
return changed;
}
@@ -1986,6 +2224,18 @@ namespace jdi
{
hoverStyle = StyleHit::WIDTH_VALUE;
}
if (hoverStyle == StyleHit::NONE && UiPointInRect(m_adjustRects[kAdjustGain], fx, fy))
{
hoverStyle = StyleHit::GAIN_ADJUST;
}
if (hoverStyle == StyleHit::NONE && UiPointInRect(m_adjustRects[kAdjustMirror], fx, fy))
{
hoverStyle = StyleHit::MIRROR_ADJUST;
}
if (hoverStyle == StyleHit::NONE && UiPointInRect(m_adjustRects[kAdjustAbsolute], fx, fy))
{
hoverStyle = StyleHit::ABSOLUTE_TOGGLE;
}
}
if (hoverRow == m_hoverRow && hoverStyle == m_hoverStyle && hoverStyleIndex == m_hoverStyleIndex &&
@@ -2061,6 +2311,8 @@ namespace jdi
m_pendingLaneMenu = false;
m_pendingColorPicker = false;
m_pendingGainMenu = false;
m_pendingMirrorMenu = false;
// 点击当前输入框以外的位置时提交输入。
if (m_editField != StyleEditField::NONE &&
@@ -2126,6 +2378,23 @@ namespace jdi
{
return BeginEdit(StyleEditField::OPACITY);
}
// 「调整」行:系数与镜像(按下只登记,**抬起**时才展开各自的二级面板,
// 与自定义颜色入口同一套交互)、绝对值(单击即切换)。
if (UiPointInRect(m_adjustRects[kAdjustGain], fx, fy))
{
m_pendingGainMenu = true;
return true;
}
if (UiPointInRect(m_adjustRects[kAdjustMirror], fx, fy))
{
m_pendingMirrorMenu = true;
return true;
}
if (UiPointInRect(m_adjustRects[kAdjustAbsolute], fx, fy))
{
style->absolute = !style->absolute;
return true;
}
}
// 列表行。
@@ -2265,6 +2534,29 @@ namespace jdi
}
}
// 「系数」/「镜像」:按下时只登记,抬起时指针仍在同一按钮上才展开二级面板
//(与自定义颜色入口一致:按住后移开再松开视为放弃)。
if (m_pendingGainMenu)
{
m_pendingGainMenu = false;
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
if (UiPointInRect(m_adjustRects[kAdjustGain], fx, fy) && m_tab != nullptr)
{
changed = OpenGainMenu() || changed;
}
}
if (m_pendingMirrorMenu)
{
m_pendingMirrorMenu = false;
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
if (UiPointInRect(m_adjustRects[kAdjustMirror], fx, fy) && m_tab != nullptr)
{
changed = OpenMirrorMenu() || changed;
}
}
if (m_pendingLaneMenu)
{
m_pendingLaneMenu = false;
@@ -2583,6 +2875,133 @@ namespace jdi
return false;
}
// 「系数」/「偏移」二级面板里的输入框提交:就地更新,面板保持打开
//(与网格 Y 轴上下限的菜单内联编辑一致),并把夹取后的真实值回写到输入框上。
if (result.editCommit)
{
SignalStyle* style = StyleOf(SelectedRef());
if (style != nullptr)
{
const std::wstring trimmed = Trim(result.editText);
wchar_t* stop = nullptr;
const double parsed =
trimmed.empty() ? 0.0 : std::wcstod(trimmed.c_str(), &stop);
// 非法/空输入保持原值(只把真实值回写到输入框,避免用户看到错误的数字)。
const bool valid = !trimmed.empty() && stop != trimmed.c_str() && std::isfinite(parsed);
switch (result.id)
{
case CMD_GAIN_EDIT:
{
if (valid)
{
style->gain =
static_cast<float>(std::max(-kGainLimit, std::min(parsed, kGainLimit)));
}
if (m_menu != nullptr)
{
m_menu->UpdateItemValue(CMD_GAIN_EDIT, FormatGainText(style->gain), FormatGainText(style->gain));
}
return false;
}
case CMD_OFFSET_Y:
{
if (valid)
{
style->offsetY = static_cast<float>(
std::max(-static_cast<double>(kGainLimit), std::min(parsed, kGainLimit)));
}
if (m_menu != nullptr)
{
m_menu->UpdateItemValue(
CMD_OFFSET_Y, FormatSignedNumber(static_cast<double>(style->offsetY)),
FormatNumberText(static_cast<double>(style->offsetY)));
}
return false;
}
case CMD_OFFSET_TIME:
{
if (valid)
{
style->offsetTime =
std::max(-kGainLimit, std::min(parsed, kGainLimit));
}
if (m_menu != nullptr)
{
m_menu->UpdateItemValue(CMD_OFFSET_TIME,
L"Δt:" + FormatDurationCompound(style->offsetTime),
FormatNumberText(style->offsetTime));
}
return false;
}
default:
break;
}
}
else
{
return false;
}
}
// 标题栏右侧的「还原」图标:系数重置为默认值 1(面板保持打开,输入框立即回显)。
if (result.id == CMD_GAIN_RESET)
{
if (SignalStyle* style = StyleOf(SelectedRef()))
{
style->gain = 1.0f;
if (m_menu != nullptr)
{
m_menu->UpdateItemValue(CMD_GAIN_EDIT, FormatGainText(style->gain), FormatGainText(style->gain));
}
}
return false;
}
// 「偏移调整」的「还原」图标:Y 与时间偏移都恢复为默认值 0。
if (result.id == CMD_OFFSET_RESET)
{
if (SignalStyle* style = StyleOf(SelectedRef()))
{
style->offsetY = 0.0f;
style->offsetTime = 0.0;
if (m_menu != nullptr)
{
m_menu->UpdateItemValue(CMD_OFFSET_Y, FormatSignedNumber(0.0), L"0.00");
m_menu->UpdateItemValue(CMD_OFFSET_TIME, L"Δt:" + FormatDurationCompound(0.0),
L"0.00");
}
}
return false;
}
// 「镜像」二级面板:两行带复选框的切换项(面板保持打开,勾选状态就地回写)。
if (result.id == CMD_MIRROR_HORIZONTAL || result.id == CMD_MIRROR_VERTICAL)
{
if (SignalStyle* style = StyleOf(SelectedRef()))
{
const bool horizontal = (result.id == CMD_MIRROR_HORIZONTAL);
bool& flag = horizontal ? style->mirrorHorizontal : style->mirrorVertical;
flag = !flag;
if (!horizontal)
{
// ⚠️ Y 偏移以**未镜像空间**为准:垂直镜像开关变化时同步取反偏移量,
// 使绘制等价于「先加上偏移再镜像」(用户明确要求)。
style->offsetY = -style->offsetY;
if (m_menu != nullptr)
{
m_menu->UpdateItemValue(
CMD_OFFSET_Y, FormatSignedNumber(static_cast<double>(style->offsetY)),
FormatNumberText(static_cast<double>(style->offsetY)));
}
}
if (m_menu != nullptr)
{
m_menu->UpdateItemLabel(result.id, horizontal ? L"水平镜像" : L"垂直镜像", flag, true);
}
}
return false;
}
bool needRelayout = false;
switch (result.id)
+22
View File
@@ -219,6 +219,17 @@ namespace jdi
*/
gd::RectF EditFieldRect() const;
/**
* @brief 把“文本区矩形”扩成**编辑态输入框**矩形(向外扩出输入框的内边距)。
*
* ⚠️ 数值区正好 3 个字符宽、色号正好 6+1 个字符宽,若直接拿文本区当输入框,
* 输入框内部的 6px 内边距会把最后一个字符裁掉。
*
* @param [IN] textRect 文本区矩形(数值区或色号区)。
* @return 输入框矩形。
*/
gd::RectF PaddedEditRect(const gd::RectF& textRect) const;
/**
* @brief 查询坐标命中的滑块下标。
* @param [IN] x 客户区横坐标。
@@ -227,6 +238,17 @@ namespace jdi
*/
int SliderAtPoint(int x, int y) const;
/**
* @brief 计算通道标签(R/G/B)的矩形。
*
* 标签**右边界**到轨道左端的距离与轨道右端到数值区的距离相等
* (用户要求:滑块两侧留白一致,三个字母纵向对齐)。
*
* @param [IN] index 通道下标(0=R,1=G,2=B)。
* @return 标签矩形;下标非法时返回空矩形。
*/
gd::RectF ChannelLabelRect(int index) const;
/**
* @brief 应用滑块拖拽位置到对应通道。
* @param [IN] index 滑块下标。
+177 -1
View File
@@ -1,6 +1,8 @@
#ifndef __DATA_MODEL_HPP__
#define __DATA_MODEL_HPP__
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <string>
@@ -90,7 +92,7 @@ namespace jdi
};
/**
* @brief 单个信号的显示外观(颜色/透明度/线宽/绘制方式)。
* @brief 单个信号的显示外观(颜色/透明度/线宽/绘制方式/数值变换)。
*
* 外观属于信号自身而非某个标签页——同一信号在所有标签页中必须显示效果一致,
* 因此在任一视图里修改颜色/线宽/透明度/样式,其它视图会自动同步。
@@ -103,8 +105,161 @@ namespace jdi
float opacity = 1.0f; /**< 不透明度,取值 0.0-1.0,默认 1.0。 */
float lineWidth = 1.0f; /**< 线宽(逻辑像素,未乘 DPI 缩放)。 */
DrawMode mode = DrawMode::LINE; /**< 绘制方式。 */
/**
* 绘制前的变换(依次作用,见 `ApplyDrawTransform`):
* 1. `gain` 系数:采样值乘以该系数(默认 1,即不改变);
* 2. `absolute` 绝对值:|value|;
* 3. 镜像:`mirrorVertical` 按 Y = 0 上下镜像(value → −value),
* `mirrorHorizontal` 为**数据反序**(把采样序列头尾对调后绘制,即水平翻转);
* 4. 偏移:`offsetY` 给绘制值加上一个常数(波形整体上下移动),
* `offsetTime` 把采样沿时间轴平移(绘制在 `t + offsetTime` 处)。
*
* ⚠️ 结算顺序固定为「系数 → 绝对值 → 镜像 → 偏移」(用户明确要求)。
* 其中镜像的两个方向互相正交(垂直改**值**、水平改**取值下标**),偏移同理
*(`offsetY` 改值、`offsetTime` 改**绘制时刻**)。
* ⚠️ `offsetY` 以**未镜像空间**为准:启用/取消垂直镜像时会同步取反(见
* `SignalStyle::offsetY` 的使用处),使绘制等价于「先加上偏移再镜像」。
*/
float gain = 1.0f; /**< 系数,默认 1.0(不改变)。 */
bool absolute = false; /**< 是否对绘制值取绝对值,默认关闭。 */
bool mirrorVertical = false; /**< 是否按 Y=0 上下镜像(值取反),默认关闭。 */
bool mirrorHorizontal = false; /**< 是否水平镜像(数据反序绘制),默认关闭。 */
float offsetY = 0.0f; /**< Y 轴偏移(叠加到绘制值上),默认 0。 */
double offsetTime = 0.0; /**< 时间轴偏移(秒,绘制在 t + offsetTime 处),默认 0。 */
/** @brief 镜像功能的任一分量是否启用(决定「镜像」按钮的激活态)。 */
bool Mirrored() const { return mirrorVertical || mirrorHorizontal; }
/**
* @brief 是否存在任何**偏移**(决定「系数/偏移」按钮的激活态之一)。
* @return Y 或时间偏移不为 0 返回 true。
*/
bool Offset() const
{
return std::fabs(offsetY) > 1.0e-9f || std::fabs(offsetTime) > 1.0e-15;
}
};
/**
* @brief 把原始采样值变换为**绘制用值**。
*
* 顺序固定为「系数 → 绝对值 → 垂直镜像 → Y 偏移」,例如系数 2、原始值 1.5 →
* 2×1.5 = 3;取绝对值仍为 3;垂直镜像则 −3;Y 偏移 +1 得 −2。
* NaN 经乘/取绝对值/取反/加偏移后仍为 NaN(保持断线语义)。
*
* ⚠️ 水平镜像与时间偏移**不在这里处理**:它们分别改变“取值下标”与“绘制时刻”,
* 必须在遍历采样时处理(见 `SourceIndexFor` 与 `offsetTime` 的使用处)。
*
* @param [IN] style 信号外观。
* @param [IN] value 原始采样值。
* @return 绘制用值。
*/
inline double ApplyDrawTransform(const SignalStyle& style, double value)
{
if (style.gain != 1.0f)
{
value *= static_cast<double>(style.gain);
}
if (style.absolute)
{
value = (value < 0.0) ? -value : value;
}
if (style.mirrorVertical)
{
value = -value;
}
if (style.offsetY != 0.0f)
{
value += static_cast<double>(style.offsetY);
}
return value;
}
/**
* @brief 把遍历下标映射为**源采样下标**(处理水平镜像的"数据反序")。
*
* 水平镜像 = 头尾对调:遍历下标 `index` 取源下标 `count - 1 - index`。
* 关闭时原样返回,因此该函数在有/无镜像时都是 O(1)、无分支代价可忽略。
*
* @param [IN] style 信号外观。
* @param [IN] count 采样点数。
* @param [IN] index 遍历下标。
* @return 源采样下标。
*/
inline std::size_t SourceIndexFor(const SignalStyle& style, std::size_t count, std::size_t index)
{
if (!style.mirrorHorizontal || count == 0)
{
return index;
}
return (count - 1) - index;
}
/**
* @brief 判断数值变换是否**保持大小顺序**(正系数、不取绝对值、不垂直镜像)。
*
* 只有保序时,粗粒度包络层(每组的 min/max)才能直接按系数缩放后继续使用;
* 取绝对值 / 垂直镜像会把最小值与最大值互换,此时必须回退到逐采样点变换。
*
* @param [IN] style 信号外观。
* @return 变换单调递增返回 true。
*/
inline bool DrawTransformOrderPreserving(const SignalStyle& style)
{
return (style.gain > 0.0f) && !style.absolute && !style.mirrorVertical;
}
/**
* @brief 把**数值区间**按绘制变换映射为变换后的区间(用于整段 Y 轴自适应)。
*
* 各步作用在区间端点上是精确的(系数为线性、绝对值为 |x|、镜像与偏移为线性),
* 因此 `[minValue, maxValue]` 映射后就是真实的数据范围,无需扫描采样点。
* ⚠️ 水平镜像与时间偏移都不改变值的范围,因此不影响本函数。
*
* @param [IN] style 信号外观。
* @param [IN,OUT] low 区间下限,返回变换后的下限。
* @param [IN,OUT] high 区间上限,返回变换后的上限。
*/
inline void TransformValueRange(const SignalStyle& style, double& low, double& high)
{
if (low > high)
{
std::swap(low, high);
}
const double gain = static_cast<double>(style.gain);
if (gain != 1.0)
{
low *= gain;
high *= gain;
if (low > high)
{
std::swap(low, high);
}
}
if (style.absolute)
{
// |x| 在 [low, high] 上的取值范围:跳 0 时从 0 起,否则从较近的一端起。
const double first = std::fabs(low);
const double second = std::fabs(high);
const bool spansZero = (low <= 0.0) && (high >= 0.0);
low = spansZero ? 0.0 : std::min(first, second);
high = std::max(first, second);
}
if (style.mirrorVertical)
{
const double flipped = -low;
low = -high;
high = flipped;
}
if (style.offsetY != 0.0f)
{
const double offset = static_cast<double>(style.offsetY);
low += offset;
high += offset;
}
}
/**
* @brief 单条信号的元数据与采样值(纯数据,不含可见性配置)。
*/
@@ -566,6 +721,27 @@ namespace jdi
bool SampleSignalAt(const DataFile& file, const SignalData& signal, double time, bool interpolate,
double& value);
/**
* @brief 取某一时刻**画面上显示**的取值(含水平镜像的"数据反序"与时间偏移)。
*
* 与 `SampleSignalAt` 的区别:
* * 水平镜像时用 `SourceIndexFor` 把下标映射到源采样(头尾对调);
* * 时间偏移时先按 `time - style.offsetTime` 求出源采样时刻(因为绘制在 `t + offsetTime`)。
* 因此返回的正是当前绘制出来的那条曲线在该时刻的取值 —— 光标读数、数据点标注都必须用它,
* 否则读数会与看到的波形不一致。
* ⚠️ 系数 / 绝对值 / 垂直镜像 / `offsetY` 不在这里处理,由 `ApplyDrawTransform` 负责。
*
* @param [IN] file 信号所属的已加载数据分组(提供时间轴)。
* @param [IN] signal 目标信号。
* @param [IN] style 显示外观(只用 `mirrorHorizontal`)。
* @param [IN] time 采样时刻。
* @param [IN] interpolate 是否在相邻采样点之间做线性插值。
* @param [OUT] value 输出采样值,缺失时为 NaN。
* @return 时间点落在数据范围内且取到有效值返回 true。
*/
bool SampleDisplayedSignalAt(const DataFile& file, const SignalData& signal,
const SignalStyle& style, double time, bool interpolate, double& value);
/**
* @brief 计算 N 点滑动平均(窗口 = [i-N, i+N],边界按实际窗口长度)。
*
+12
View File
@@ -115,6 +115,18 @@ namespace jdi
*/
void UiDrawRoundRect(gd::Graphics& g, const gd::RectF& rect, float radius, std::uint32_t argb, float width);
/**
* @brief 构造圆角矩形路径(与 `UiFillRoundRect` / `UiDrawRoundRect` 使用完全相同的几何)。
*
* 需要把一张位图 / 渐变**裁剪进圆角形状**时使用:
* `g.SetClip(&path)` 之后再填充,就不会在圆角之外漏出直角矩形的角。
*
* @param [IN,OUT] path 目标路径对象(不会被清空,直接追加)。
* @param [IN] rect 目标矩形。
* @param [IN] radius 圆角半径。
*/
void UiBuildRoundRectPath(gd::GraphicsPath& path, const gd::RectF& rect, float radius);
/**
* @brief 绘制一条直线。
* @param [IN,OUT] g 绘图上下文。
+59 -3
View File
@@ -37,7 +37,8 @@ namespace jdi
HEADER = 2, /**< 分组标题(不可点击)。 */
SWATCHES = 3, /**< 颜色色块行。 */
EDIT = 4, /**< 带内联输入框的项:在菜单内部直接编辑数值(菜单不关闭)。 */
SUBMENU = 5 /**< 带二级子菜单的项:鼠标悬停时向右展开。 */
SUBMENU = 5, /**< 带二级子菜单的项:鼠标悬停时向右展开。 */
HEADER_ACTION = 6 /**< 分组标题 + **右侧图标按钮**(如「还原」),图标可点击。 */
};
/**
@@ -54,8 +55,28 @@ namespace jdi
std::uint32_t swatches[16] = {}; /**< 色块颜色。 */
int checkedSwatch = -1; /**< 被选中的色块下标。 */
std::wstring value; /**< EDIT 项的当前数值文本(非编辑态显示)。 */
/**
* EDIT 项**进入编辑时的初始文本**;为空则使用 `value`。
*
* 用于「非编辑态的显示文本」与「编辑态的数值」不一致的场合,例如时间轴偏移:
* 平时显示 `Δt:5s 60ms`,进入编辑后应显示以秒为单位的数值 `5.06`。
*/
std::wstring editValue;
/**
* EDIT 项**编辑态**在输入框最右侧提示的单位文本(如 `s`);为空则不提示。
*
* 非编辑态不显示(那时显示的是 `value` 的完整文案)。
*/
std::wstring editSuffix;
EditFilter filter = EditFilter::ANY; /**< EDIT 项的输入过滤规则。 */
std::vector<MenuItem> submenu; /**< 二级子菜单项(kind == SUBMENU 时有效)。 */
/**
* 普通项被点击后是否**保持菜单打开**。
*
* 用于「带复选框的切换项」(如镜像的水平/垂直):可以连续勾选多项,
* 勾选状态通过 `UpdateItemLabel` 回写。默认 false(点击即关):与既有行为一致。
*/
bool keepOpen = false;
};
/**
@@ -223,10 +244,14 @@ namespace jdi
/**
* @brief 更新指定命令项显示的值文本(菜单保持打开,立即重新排布)。
* @param [IN] id 命令标识。
* @param [IN] value 新的值文本。
* @param [IN] value 新的值文本(非编辑态显示)。
* @param [IN] editValue 新的**编辑初值**(为空时保持原值)。
* ⚠️ 非编辑态文本与编辑态文本不一致的行(如时间轴偏移:`Δt:1h 1min 2s` ↔ `3662`)
* 必须一并更新,否则下次进入编辑会拿到陈旧的初值。
* @return 找到对应项并更新返回 true。
*/
bool UpdateItemValue(int id, const std::wstring& value);
bool UpdateItemValue(int id, const std::wstring& value,
const std::wstring& editValue = std::wstring());
/**
* @brief 更新指定普通项的文字与可用/选中状态(用于实时反映状态)。
@@ -283,6 +308,28 @@ namespace jdi
*/
gd::RectF EditFieldRect(int index) const;
/**
* @brief 计算 EDIT 项**编辑态单位后缀**所占的宽度(0 表示无后缀)。
*
* 排版、命中测试与绘制共用:输入框正文区会向右让出这么多宽度,
* 后缀文本绘制在它右侧(靠右对齐)。
*
* @param [IN] item 菜单项。
* @return 后缀宽度(像素)。
*/
float EditSuffixWidth(const MenuItem& item) const;
/**
* @brief 计算 `HEADER_ACTION` 项右侧**图标按钮**(如「还原」)的矩形。
*
* 图标紧贴该行右端(右边距与其它行一致、行内垂直居中),大小按缩放比例推导。
* 鼠标悬停时该图标会高亮,点击即下发该项的命令。
*
* @param [IN] index 项目下标。
* @return 图标矩形;非 `HEADER_ACTION` 项或非法下标返回空矩形。
*/
gd::RectF HeaderIconRect(int index) const;
/**
* @brief 计算 EDIT 项输入框的“自然宽度”(排版与绘制共用同一公式)。
* @param [IN] item 菜单项。
@@ -350,9 +397,18 @@ namespace jdi
gd::RectF m_rect; /**< 菜单整体矩形。 */
std::vector<gd::RectF> m_itemRects; /**< 各项矩形。 */
/**
* 同一次排布中,**带说明文字的 EDIT 项**共用的输入框宽度(0 表示没有这类项)。
*
* 宽度取这些项里最宽的自然宽度,并在排布时按“不能挤掉说明文字 / 不超过内容区”夹好。
* 同一面板内的输入框因此一样宽、右边界对齐(用户要求「Y轴」与「时间轴」等宽对齐,
* 而不是各自按自己的文本长度自适应)。
*/
float m_editFieldWidth = 0.0f;
std::vector<std::vector<gd::RectF>> m_swatchRects; /**< 各项内的色块矩形。 */
int m_hoverIndex = -1; /**< 当前悬停项。 */
int m_hoverIconIndex = -1; /**< 当前悬停的 `HEADER_ACTION` 图标所在项下标。 */
int m_hoverSwatch = -1; /**< 当前悬停色块。 */
int m_pressedIndex = -1; /**< 按下时的项目下标。 */
int m_pressedSwatch = -1; /**< 按下时的色块下标。 */
+24
View File
@@ -97,6 +97,30 @@ namespace jdi
*/
std::wstring FormatCount(unsigned long long value);
/**
* @brief 把时间间隔格式化为「Δt 语义」的复合单位文本(不含 "Δt:" 前缀)。
*
* 规则(用户明确要求,与双光标差值的显示完全一致):
* - 顶级单位高于 s(即 h / min)时**一路整合到 s**,例如 3662s → "1h 1min 2s";
* - 顶级单位为 s 及以下时,若不是该单位的整数,则**向下一级递进**,
* 例如 5.06s → "5s 60ms";整秒数不加下级单位,例如 5s → "5s";
* - 顶级单位为 ps 时不再递进次级单位,例如 5.123ps → "5.123 ps"。
*
* @param [IN] seconds 时间间隔(秒)。
* @return 形如 "1h 1min 2s" / "5s 60ms" 的间隔文本。
*/
std::wstring FormatDurationCompound(double seconds);
/**
* @brief 把时间间隔格式化为「秒」数值文本(不含单位),用于内联编辑。
*
* 编辑时的量级**始终是秒**:即使显示为 "1h 1min 2s",进入编辑后也是 3602。
*
* @param [IN] seconds 时间间隔(秒)。
* @return 形如 "3602" 的文本。
*/
std::wstring FormatDurationSeconds(double seconds);
/**
* @brief 计算“美观”的坐标轴刻度步长(1/2/5 乘以 10 的整数次幂)。
* @param [IN] range 坐标轴数值跨度,必须为正数。
+132 -66
View File
@@ -18,11 +18,14 @@ namespace jdi
const float kTitleGap = 8.0f; /**< 标题与内容区间距。 */
const float kRowHeight = 26.0f; /**< 单个滑块行高度。 */
const float kRowGap = 2.0f; /**< 滑块行间距。 */
const float kPreviewGap = 10.0f; /**< 滑块区与预览区间距。 */
const float kPreviewHeight = 24.0f; /**< 预览行高度。 */
const float kPreviewHeight = 24.0f; /**< 预览行(预览色块 + 色号)高度。 */
const float kLabelWidth = 16.0f; /**< 通道标签宽度。 */
const float kLabelGap = 6.0f; /**< 标签与滑块间距。 */
const float kValueWidth = 40.0f; /**< 数值文本宽度。 */
/**
* 滑块**两侧**的间隙(左:通道字母 ↔ 轨道;右:轨道 ↔ 数值区)。
*
* ⚠️ 必须共用同一个常量:用户明确要求轨道左右两侧的留白相等。
*/
const float kSliderGap = 8.0f;
const float kTrackHeight = 8.0f; /**< 滑块轨道高度。 */
const float kCorner = 6.0f; /**< 面板圆角半径。 */
/** 色域方块 / 色相条 / 预览色块 / 色号框共用同一圆角(视觉上统一)。 */
@@ -32,7 +35,7 @@ namespace jdi
const float kGamutGap = 10.0f; /**< 色域方块与色相条间距。 */
const float kColumnGap = 12.0f; /**< 色相条与滑块列间距。 */
const float kSwatchWidth = 52.0f; /**< 预览色块宽度。 */
const float kSwatchHexGap = 8.0f; /**< 预览色块与色号间距。 */
const float kSwatchHexGap = 8.0f; /**< 预览色块与色号间距(下限,空间不足时以右对齐优先)。 */
const float kGamutBorder = 1.0f; /**< 色域方块/色相条描边宽度。 */
/** @brief 通道显示名称。 */
@@ -220,13 +223,14 @@ namespace jdi
void ColorPickerPopup::BuildLayout(const gd::RectF& bounds, const gd::RectF& anchor)
{
// 布局:左侧一列放"色域方块 + 色相条",右侧一列放 R/G/B 三行
//(每行 = 通道标签 + 滑块 + 数值),两列下方再放"预览色块 + 色号"一行。
// 布局:左侧一列放"色域方块 + 色相条",右侧一列**上方**放"预览色块 + 色号"、
// **下方**放 R/G/B 三行(每行 = 通道标签 + 滑块 + 数值)。
// 右列总高 = 预览行 + 间隙 + 三行滑块,与左侧"色域方块"高度**严格相等**,
// 因此两列上下边对齐、整体版面协调(不再有单起一行的底部预览条)。
// 所有控件都从面板内边距起算,因此不会出现越界。
const float width = kPanelWidth * m_scale;
const float height = (kPadV * 2.0f + kTitleHeight + kTitleGap + kGamutSize + kPreviewGap +
kPreviewHeight) *
m_scale;
const float height =
(kPadV * 2.0f + kTitleHeight + kTitleGap + kGamutSize) * m_scale;
const float gap = 4.0f * m_scale;
@@ -260,38 +264,49 @@ namespace jdi
m_hueRect = gd::RectF(m_gamutRect.X + m_gamutRect.Width + kGamutGap * m_scale, contentTop,
kHueWidth * m_scale, kGamutSize * m_scale);
// 右列:R/G/B 三行。整列在方块高度内纵向居中,与左侧方块视觉平衡。
const float leftColumnWidth =
(kGamutSize + kGamutGap + kHueWidth) * m_scale;
// 右列:预览行在滑块之上,整列高度与左侧方块一致。
const float leftColumnWidth = (kGamutSize + kGamutGap + kHueWidth) * m_scale;
const float columnLeft = m_gamutRect.X + leftColumnWidth + kColumnGap * m_scale;
const float columnWidth = std::max(60.0f * m_scale, contentRight - columnLeft);
const float rowsHeight = (kRowHeight * 3.0f + kRowGap * 2.0f) * m_scale;
const float firstRowTop =
contentTop + std::max(0.0f, (kGamutSize * m_scale - rowsHeight) * 0.5f);
const float trackLeft = columnLeft + (kLabelWidth + kLabelGap) * m_scale;
const float trackWidth =
std::max(24.0f, columnWidth - (kLabelWidth + kLabelGap + kValueWidth + 6.0f) * m_scale);
// 预览行与滑块之间的间隙:正好补齐"方块高度 − 预览行高 − 三行滑块高",
// 使右列总高与左侧色域方块严格相等(常量变化时自动适配)。
const float previewRowGap =
std::max(0.0f, kGamutSize - kPreviewHeight - (kRowHeight * 3.0f + kRowGap * 2.0f)) * m_scale;
// 通道数值区固定占 **3 个字符宽**(0-255 最多三位):与数字本身的宽度无关,
// 这样三行数值的右边界严格一致(右对齐后三列数字纵向对齐)。
const float valueWidth =
std::max(1.0f, UiMeasureTextWidth(m_fonts->monoSmall, L"000"));
// 轨道两侧用**同一个**间隙(用户要求两侧留白相等)。
const float sliderGap = kSliderGap * m_scale;
const float labelWidth = kLabelWidth * m_scale;
const float trackLeft = columnLeft + labelWidth + sliderGap;
const float trackWidth = std::max(
24.0f, columnWidth - (labelWidth + sliderGap + sliderGap + valueWidth));
const float valueLeft = trackLeft + trackWidth + sliderGap;
const float valueRight = valueLeft + valueWidth;
// 预览色块的**左边界与轨道左边界对齐**(长宽不变)。
m_previewRect =
gd::RectF(trackLeft, contentTop, kSwatchWidth * m_scale, kPreviewHeight * m_scale);
// 色号 "#RRGGBB" 固定 **6+1 个字符宽**,并且**右边界与三行数值的右边界对齐**。
const float hexWidth = UiMeasureTextWidth(m_fonts->monoSmall, L"#000000");
const float hexLeft = std::max(m_previewRect.X + m_previewRect.Width + kSwatchHexGap * m_scale,
valueRight - hexWidth);
m_hexRect = gd::RectF(hexLeft, contentTop, valueRight - hexLeft, kPreviewHeight * m_scale);
const float sliderTop = m_previewRect.Y + m_previewRect.Height + previewRowGap;
for (int index = 0; index < 3; ++index)
{
const float rowTop = firstRowTop + static_cast<float>(index) * (kRowHeight + kRowGap) * m_scale;
const float rowTop = sliderTop + static_cast<float>(index) * (kRowHeight + kRowGap) * m_scale;
m_tracks[static_cast<std::size_t>(index)] =
gd::RectF(trackLeft, rowTop + (kRowHeight * m_scale - kTrackHeight * m_scale) * 0.5f,
trackWidth, kTrackHeight * m_scale);
m_values[static_cast<std::size_t>(index)] =
gd::RectF(trackLeft + trackWidth + 6.0f * m_scale, rowTop, kValueWidth * m_scale,
kRowHeight * m_scale);
gd::RectF(valueLeft, rowTop, valueWidth, kRowHeight * m_scale);
}
// 下行:预览色块 + 色号,与上方的"方块 + 色相条"同宽。
const float previewTop = contentTop + (kGamutSize + kPreviewGap) * m_scale;
m_previewRect =
gd::RectF(contentLeft, previewTop, kSwatchWidth * m_scale, kPreviewHeight * m_scale);
m_hexRect = gd::RectF(m_previewRect.X + m_previewRect.Width + kSwatchHexGap * m_scale,
previewTop,
std::max(40.0f * m_scale, leftColumnWidth - (kSwatchWidth + kSwatchHexGap) * m_scale),
kPreviewHeight * m_scale);
}
void ColorPickerPopup::Open(const gd::RectF& bounds, const gd::RectF& anchor, std::uint32_t color)
@@ -448,19 +463,42 @@ namespace jdi
switch (m_editField)
{
case EditField::R:
return m_values[0];
return PaddedEditRect(m_values[0]);
case EditField::G:
return m_values[1];
return PaddedEditRect(m_values[1]);
case EditField::B:
return m_values[2];
return PaddedEditRect(m_values[2]);
case EditField::HEX:
return m_hexRect;
return PaddedEditRect(m_hexRect);
case EditField::NONE:
default:
return gd::RectF();
}
}
gd::RectF ColorPickerPopup::PaddedEditRect(const gd::RectF& textRect) const
{
// 与 `UiDrawEditField` 的内边距(6*scale)一致:文本区向外扩这么多,
// 编辑态才能完整显示 3 位数值 / 7 位色号(否则最后一个字符被裁掉)。
const float padding = 6.0f * m_scale;
return gd::RectF(textRect.X - padding, textRect.Y, textRect.Width + padding * 2.0f,
textRect.Height);
}
gd::RectF ColorPickerPopup::ChannelLabelRect(int index) const
{
if (index < 0 || index >= 3)
{
return gd::RectF();
}
const std::size_t slot = static_cast<std::size_t>(index);
const gd::RectF& track = m_tracks[slot];
// 间隙直接由实际几何推出(轨道右端 → 数值区左端),因此标签侧与数值侧**必然**相等。
const float gap = std::max(0.0f, m_values[slot].X - (track.X + track.Width));
return gd::RectF(track.X - gap - kLabelWidth * m_scale, m_values[slot].Y,
kLabelWidth * m_scale, kRowHeight * m_scale);
}
bool ColorPickerPopup::BeginEdit(EditField field)
{
if (!m_open || field == EditField::NONE)
@@ -797,16 +835,18 @@ namespace jdi
}
// 数值与色号文本优先于滑块:两者几何重叠时要能进入输入状态。
// 命中区用**编辑框**矩形(比 3 / 7 个字符宽的文本区宽出输入框内边距):
// 文本区只有 3 个字符宽,命中区太小不便点击。
for (int index = 0; index < 3; ++index)
{
if (UiPointInRect(m_values[static_cast<std::size_t>(index)], static_cast<float>(x),
static_cast<float>(y)))
if (UiPointInRect(PaddedEditRect(m_values[static_cast<std::size_t>(index)]),
static_cast<float>(x), static_cast<float>(y)))
{
BeginEdit(static_cast<EditField>(index + 1));
return true;
}
}
if (UiPointInRect(m_hexRect, static_cast<float>(x), static_cast<float>(y)))
if (UiPointInRect(PaddedEditRect(m_hexRect), static_cast<float>(x), static_cast<float>(y)))
{
BeginEdit(EditField::HEX);
return true;
@@ -925,35 +965,57 @@ namespace jdi
// ---- 色域方块(横轴饱和度、纵轴明度)----
// 方块按当前色相预渲染到缓存位图里,避免每帧重画上百条渐变;
// 色相变化时才重建一次。
//
// ⚠️ 填充必须**裁剪进圆角路径**里:
// * 缓存的位图是直角矩形,直接贴上去会在四个角的多出来像素(用户报告的"角落里出现
// 莫名其妙的黑色像素点"——方块左下/右下本身就是接近黑色的饱和/明度极值)。
// * `WrapModeTileFlipXY` 让采样到边界之外的插值镜像取边,不再混入透明黑,
// 边界也就不会再被"压暗"出一圈深色。
if (EnsureGamutCache())
{
g.DrawImage(m_gamutCache.get(), m_gamutRect);
gd::GraphicsPath gamutPath;
UiBuildRoundRectPath(gamutPath, m_gamutRect, kSwatchCorner * m_scale);
gd::ImageAttributes attributes;
attributes.SetWrapMode(gd::WrapModeTileFlipXY, gd::Color(), FALSE);
const int saveState = g.Save();
g.SetClip(&gamutPath);
g.DrawImage(m_gamutCache.get(), m_gamutRect, 0.0f, 0.0f,
static_cast<gd::REAL>(m_gamutCacheSize),
static_cast<gd::REAL>(m_gamutCacheSize), gd::UnitPixel, &attributes);
g.Restore(saveState);
}
UiDrawRoundRect(g, m_gamutRect, kSwatchCorner * m_scale, m_theme->border, kGamutBorder);
// ---- 色相条(纵向 0-1):6 段线性渐变拼出完整色相环 ----
// ---- 色相条(纵向 0-1)----
{
for (int segment = 0; segment < 6; ++segment)
// 一条带 7 个色标的线性渐变画满整条(0、1/6 … 1,每段内 RGB 线性):
// 与"6 段拼色"的颜色完全一致,但不会有段与段之间的接缝横线。
gd::Color stops[7];
gd::REAL positions[7];
for (int index = 0; index < 7; ++index)
{
const double hue0 = static_cast<double>(segment) / 6.0;
const double hue1 = static_cast<double>(segment + 1) / 6.0;
const std::array<double, 3> rgb0 = FromHsv(hue0, 1.0, 1.0);
const std::array<double, 3> rgb1 = FromHsv(hue1, 1.0, 1.0);
const std::uint32_t color0 =
0xFF000000u | (ToByte(rgb0[0]) << 16) | (ToByte(rgb0[1]) << 8) | ToByte(rgb0[2]);
const std::uint32_t color1 =
0xFF000000u | (ToByte(rgb1[0]) << 16) | (ToByte(rgb1[1]) << 8) | ToByte(rgb1[2]);
const float top = m_hueRect.Y + m_hueRect.Height * static_cast<float>(hue0);
const float bottom = m_hueRect.Y + m_hueRect.Height * static_cast<float>(hue1);
const gd::Point from(static_cast<INT>(std::lround(m_hueRect.X)),
static_cast<INT>(std::lround(top)));
const gd::Point to(static_cast<INT>(std::lround(m_hueRect.X)),
static_cast<INT>(std::lround(bottom)));
gd::LinearGradientBrush brush(from, to, ToGdiColor(color0), ToGdiColor(color1));
// 渐变端点会各自向两侧延伸半段,逐段微重叠地填充即可无缝拼接。
g.FillRectangle(&brush, m_hueRect.X, top, m_hueRect.Width, bottom - top);
const double hue = static_cast<double>(index) / 6.0;
const std::array<double, 3> rgb = FromHsv((hue > 1.0) ? 1.0 : hue, 1.0, 1.0);
stops[index] = ToGdiColor(0xFF000000u | (ToByte(rgb[0]) << 16) | (ToByte(rgb[1]) << 8) |
ToByte(rgb[2]));
positions[index] = static_cast<gd::REAL>(static_cast<double>(index) / 6.0);
}
gd::LinearGradientBrush brush(
gd::Point(static_cast<INT>(std::lround(m_hueRect.X)),
static_cast<INT>(std::lround(m_hueRect.Y))),
gd::Point(static_cast<INT>(std::lround(m_hueRect.X)),
static_cast<INT>(std::lround(m_hueRect.Y + m_hueRect.Height))),
stops[0], stops[6]);
brush.SetInterpolationColors(stops, positions, 7);
// 与色域方块**共用同一个圆角**(`kSwatchCorner`),并且同样裁剪进圆角路径:
// 直角填充会在圆角描边之外露出四个尖角(用户报告的"四个角的杂色像素点")。
gd::GraphicsPath huePath;
UiBuildRoundRectPath(huePath, m_hueRect, kSwatchCorner * m_scale);
const int saveState = g.Save();
g.SetClip(&huePath);
g.FillRectangle(&brush, m_hueRect);
g.Restore(saveState);
UiDrawRoundRect(g, m_hueRect, kSwatchCorner * m_scale, m_theme->border, kGamutBorder);
// ---- 色域方块上的选点:中心就是当前选中的颜色,外面只加一圈白色描边 ----
@@ -989,17 +1051,19 @@ namespace jdi
const std::size_t slot = static_cast<std::size_t>(index);
const gd::RectF& track = m_tracks[slot];
// 通道标签放在滑块同一行的左侧,与轨道/数值纵向对齐。
const gd::RectF labelRect(track.X - (kLabelWidth + kLabelGap) * m_scale, m_values[slot].Y,
kLabelWidth * m_scale, kRowHeight * m_scale);
UiText(g, kChannelNames[index], m_fonts->uiSmall, labelRect, m_theme->textMuted, TextAlign::LEFT);
// 通道标签放在滑块同一行的左侧,**右对齐**到"轨道左端 − 间隙":
// 这样 R/G/B 三个字母的右边界与轨道的距离严格等于轨道到数值区的距离
// (用户要求两侧留白相同,且三个字母纵向对齐)。
const gd::RectF labelRect = ChannelLabelRect(index);
UiText(g, kChannelNames[index], m_fonts->uiSmall, labelRect, m_theme->textMuted,
TextAlign::RIGHT);
const bool active = (m_draggingSlider == index) || (m_hoverSlider == index);
DrawChannelSlider(g, track, m_channels[slot], kChannelColors[index], active, m_scale);
if (m_editField == static_cast<EditField>(index + 1))
{
UiDrawEditField(g, m_values[slot], m_edit, m_fonts->monoSmall, m_scale);
UiDrawEditField(g, PaddedEditRect(m_values[slot]), m_edit, m_fonts->monoSmall, m_scale);
}
else
{
@@ -1015,14 +1079,16 @@ namespace jdi
if (m_editField == EditField::HEX)
{
UiDrawEditField(g, m_hexRect, m_edit, m_fonts->monoSmall, m_scale);
UiDrawEditField(g, PaddedEditRect(m_hexRect), m_edit, m_fonts->monoSmall, m_scale);
}
else
{
wchar_t buffer[16];
swprintf(buffer, sizeof(buffer) / sizeof(buffer[0]), L"#%02X%02X%02X", (color >> 16) & 0xFFu,
(color >> 8) & 0xFFu, color & 0xFFu);
UiText(g, std::wstring(buffer), m_fonts->monoSmall, m_hexRect, m_theme->text, TextAlign::LEFT);
// 与三行数值一样**右对齐**:色号的最后一位与数值的最后一位纵向对齐。
UiText(g, std::wstring(buffer), m_fonts->monoSmall, m_hexRect, m_theme->text,
TextAlign::RIGHT);
}
}
+89 -2
View File
@@ -1583,6 +1583,84 @@ namespace jdi
return true;
}
bool SampleDisplayedSignalAt(const DataFile& file, const SignalData& signal,
const SignalStyle& style, double time, bool interpolate, double& value)
{
// 未启用水平镜像 / 时间偏移时与 SampleSignalAt 完全等价
//(避免两套实现产生细微差异)。
if (!style.mirrorHorizontal && style.offsetTime == 0.0)
{
return SampleSignalAt(file, signal, time, interpolate, value);
}
value = kMissing;
if (file.time.empty() || signal.values.empty())
{
return false;
}
// 绘制位置 = 源时刻 + offsetTime,因此查询源数据要用「绘制时刻 − offsetTime」。
const double query = time - style.offsetTime;
if (query < file.minTime || query > file.maxTime)
{
return false;
}
const std::vector<double>& values = signal.values;
const std::size_t count = std::min(file.time.size(), values.size());
if (count == 0)
{
return false;
}
// 画面上第 i 个位置用源下标 srcIndex(i) 的值(水平镜像 = 数据反序)。
const auto sample = [&](std::size_t index) -> double
{
return values[SourceIndexFor(style, count, index)];
};
if (!file.timeMonotonic)
{
const int nearest = FindNearestSample(file, query);
if (nearest < 0 || static_cast<std::size_t>(nearest) >= count)
{
return false;
}
value = sample(static_cast<std::size_t>(nearest));
return std::isfinite(value);
}
const std::vector<double>::const_iterator lower =
std::lower_bound(file.time.begin(), file.time.end(), query);
const std::size_t rightIndex = static_cast<std::size_t>(lower - file.time.begin());
if (rightIndex == 0)
{
value = sample(0);
return std::isfinite(value);
}
if (rightIndex >= count)
{
value = sample(count - 1);
return std::isfinite(value);
}
const std::size_t leftIndex = rightIndex - 1;
const double t0 = file.time[leftIndex];
const double t1 = file.time[rightIndex];
const double v0 = sample(leftIndex);
const double v1 = sample(rightIndex);
if (!interpolate || !std::isfinite(v0) || !std::isfinite(v1) || !(t1 > t0))
{
const double leftDistance = std::fabs(query - t0);
const double rightDistance = std::fabs(t1 - query);
value = (leftDistance <= rightDistance) ? v0 : v1;
return std::isfinite(value);
}
const double ratio = (query - t0) / (t1 - t0);
value = v0 + (v1 - v0) * ratio;
return true;
}
// ============================ TabConfig ============================
TabConfig::TabConfig()
@@ -2234,8 +2312,17 @@ namespace jdi
{
continue;
}
low = std::min(low, signal->minValue);
high = std::max(high, signal->maxValue);
// ⚠️ 自适应必须基于**画出来的曲线**:先按绘制变换(系数 → 绝对值 → 镜像)把该信号的
// 原始值域映射成显示值域,否则调整系数/镜像后自适应范围仍按原始数据算(范围错误)。
const SignalStyle* style = dataSet.Style(ref);
double signalLow = signal->minValue;
double signalHigh = signal->maxValue;
if (style != nullptr)
{
TransformValueRange(*style, signalLow, signalHigh);
}
low = std::min(low, signalLow);
high = std::max(high, signalHigh);
}
if (!std::isfinite(low) || !std::isfinite(high))
+9 -10
View File
@@ -29,14 +29,11 @@ namespace jdi
* 固定开销反而不划算,因此在阈值以下仍走 GDI+。
*/
const float kGdiFillAreaThreshold = 20000.0f;
}
/**
* @brief 构造圆角矩形路径。
* @param [IN,OUT] path 目标路径对象。
* @param [IN] rect 目标矩形。
* @param [IN] radius 圆角半径。
*/
void BuildRoundRectPath(gd::GraphicsPath& path, const gd::RectF& rect, float radius)
// 圆角矩形路径是公开的几何构建器:`UiFillRoundRect` / `UiDrawRoundRect` / 需要把位图或渐变
// **裁剪进同一个圆角形状**的调用方共用它,保证"填充的形状"与"描边的形状"逐点一致。
void UiBuildRoundRectPath(gd::GraphicsPath& path, const gd::RectF& rect, float radius)
{
if (rect.Width <= 0.0f || rect.Height <= 0.0f)
{
@@ -68,6 +65,8 @@ namespace jdi
path.CloseFigure();
}
namespace
{
/**
* @brief 创建指定字号的界面字体。
* @param [IN] face 字体名称。
@@ -224,7 +223,7 @@ namespace jdi
return;
}
gd::GraphicsPath path;
BuildRoundRectPath(path, rect, radius);
UiBuildRoundRectPath(path, rect, radius);
gd::SolidBrush brush(ToGdiColor(argb));
g.FillPath(&brush, &path);
}
@@ -236,7 +235,7 @@ namespace jdi
return;
}
gd::GraphicsPath path;
BuildRoundRectPath(path, rect, radius);
UiBuildRoundRectPath(path, rect, radius);
gd::Pen pen(ToGdiColor(argb), width);
g.DrawPath(&pen, &path);
}
@@ -255,7 +254,7 @@ namespace jdi
{
continue;
}
BuildRoundRectPath(path, rect, radius);
UiBuildRoundRectPath(path, rect, radius);
}
if (path.GetPointCount() > 0)
{
+239 -14
View File
@@ -27,6 +27,54 @@ namespace jdi
const float kEditFieldPadV = 5.0f; /**< 输入框上下内缩。 */
const float kSubArrowSize = 4.5f; /**< 子菜单指示箭头尺寸。 */
const float kSubOverlap = 2.0f; /**< 子菜单与主菜单重叠的宽度(避免中间露出缝隙)。 */
const float kHeaderIconSize = 16.0f; /**< `HEADER_ACTION` 右侧图标按钮的边长。 */
const float kHeaderIconGap = 6.0f; /**< 标题文字与图标按钮之间的最小间隔。 */
const float kRowFieldWidth = 132.0f; /**< 整行输入框(无说明文字)的默认宽度。 */
/**
* @brief 绘制「还原/重置」图标(逆时针回环箭头)。
*
* 用一段圆弧 + 一个三角箭头拼成,与界面里其它自绘图标(加号、叉号)保持同一笔画风格。
* 不依赖任何字体或资源,因此在任何 DPI 下都清晰。
*
* @param [IN,OUT] g 绘图上下文。
* @param [IN] rect 图标外接矩形(正方形)。
* @param [IN] color 笔画颜色。
* @param [IN] scale DPI 缩放比例。
*/
void PaintResetIcon(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);
}
}
PopupMenu::PopupMenu() = default;
@@ -49,6 +97,7 @@ namespace jdi
case MenuItemKind::SEPARATOR:
return kSeparatorHeight * m_scale;
case MenuItemKind::HEADER:
case MenuItemKind::HEADER_ACTION:
return kHeaderHeight * m_scale;
case MenuItemKind::SWATCHES:
return kSwatchRowHeight * m_scale;
@@ -88,11 +137,14 @@ namespace jdi
static_cast<float>(std::max(0, count - 1)) * kSwatchGap * m_scale;
contentWidth = std::max(contentWidth, width);
}
else if (item.kind == MenuItemKind::HEADER)
else if (item.kind == MenuItemKind::HEADER || item.kind == MenuItemKind::HEADER_ACTION)
{
// 标题与普通项共用同一文字列与右侧标记列,保证整个菜单的左右边距一致。
const float iconExtra = (item.kind == MenuItemKind::HEADER_ACTION)
? (kHeaderIconSize + kHeaderIconGap) * m_scale
: 0.0f;
const float width = (kPadH + kItemPad) * 2.0f * m_scale + kMarkerColumn * m_scale +
kMarkerGap * m_scale +
kMarkerGap * m_scale + iconExtra +
UiMeasureTextWidth(m_fonts->uiSmallBold, item.label);
contentWidth = std::max(contentWidth, width);
}
@@ -117,6 +169,33 @@ namespace jdi
const float width = contentWidth;
const float height = totalHeight;
// 带说明文字的 EDIT 项共用**同一个输入框宽度**:取其中最大的自然宽度,
// 再按“给最宽的说明文字留位 / 不超过内容区 3/4”夹取。
// ⚠️ 若每项各自按自己的文本长度算,同一面板里就会出现一个宽一个窄
// (时间轴行的文本更长、还带单位后缀,于是它的输入框比 Y 轴行宽一截)。
float labeledFieldWidth = 0.0f;
float widestLabel = 0.0f;
for (const MenuItem& item : m_items)
{
if (item.kind != MenuItemKind::EDIT || item.label.empty())
{
continue;
}
labeledFieldWidth = std::max(labeledFieldWidth, NaturalEditFieldWidth(item));
widestLabel = std::max(widestLabel,
UiMeasureTextWidth(m_fonts->uiSmall, item.label) +
kEditFieldGap * m_scale);
}
if (labeledFieldWidth > 0.0f)
{
const float rowContent =
std::max(40.0f * m_scale, (width - 2.0f * (kPadH + kItemPad) * m_scale));
const float limit = std::max(40.0f * m_scale,
std::min(rowContent * 0.75f, rowContent - widestLabel));
labeledFieldWidth = std::min(labeledFieldWidth, limit);
}
m_editFieldWidth = labeledFieldWidth;
float left = static_cast<float>(x);
float top = static_cast<float>(y);
@@ -163,8 +242,13 @@ namespace jdi
float PopupMenu::NaturalEditFieldWidth(const MenuItem& item) const
{
return std::max(kEditFieldWidth * m_scale,
UiMeasureTextWidth(m_fonts->uiSmall, item.value) + 20.0f * m_scale);
const std::wstring& widest =
(item.editValue.size() > item.value.size()) ? item.editValue : item.value;
const float natural = std::max(kEditFieldWidth * m_scale,
UiMeasureTextWidth(m_fonts->uiSmall, widest) + 20.0f * m_scale);
const float withSuffix = natural + EditSuffixWidth(item);
// 无说明文字 = 整行输入框(如「系数调整」面板):给一个更宽的默认值,视觉上独占整行。
return item.label.empty() ? std::max(withSuffix, kRowFieldWidth * m_scale) : withSuffix;
}
void PopupMenu::CloseSubmenu()
@@ -292,6 +376,15 @@ namespace jdi
return -1;
}
float PopupMenu::EditSuffixWidth(const MenuItem& item) const
{
if (item.kind != MenuItemKind::EDIT || item.editSuffix.empty())
{
return 0.0f;
}
return UiMeasureTextWidth(m_fonts->uiSmall, item.editSuffix) + 8.0f * m_scale;
}
gd::RectF PopupMenu::EditFieldRect(int index) const
{
if (index < 0 || index >= static_cast<int>(m_items.size()))
@@ -306,23 +399,56 @@ namespace jdi
// 输入框靠右对齐到条目内容区右边界;宽度取自然宽度,但必须给说明文字留出自然宽度,
// 避免菜单被最大宽度夹取后输入框把左侧标签挤成省略号。
// ⚠️ 说明文字为空(例如「系数调整」面板的整行输入框)时不设 3/4 上限,
// 让输入框独占整行。
// ⚠️ 带说明文字的项统一使用排布阶段算好的 `m_editFieldWidth`:
// 同一面板里所有这类输入框必须一样宽、右边界对齐。
const gd::RectF& rect = m_itemRects[static_cast<std::size_t>(index)];
const float contentWidth = std::max(40.0f * m_scale, rect.Width - 2.0f * kItemPad * m_scale);
const float labelWidth = UiMeasureTextWidth(m_fonts->uiSmall, item.label) + kEditFieldGap * m_scale;
float width = NaturalEditFieldWidth(item);
width = std::min(width, std::max(40.0f * m_scale, contentWidth - labelWidth));
if (item.label.empty())
{
// 整行输入框(无说明文字):占满内容区,视觉上就是一个独立的一行输入框。
width = contentWidth;
}
else
{
if (m_editFieldWidth > 0.0f)
{
width = m_editFieldWidth;
}
width = std::min(width, std::max(40.0f * m_scale, contentWidth - labelWidth));
width = std::min(width, contentWidth * 0.75f);
}
const float height = rect.Height - kEditFieldPadV * 2.0f * m_scale;
return gd::RectF(rect.X + rect.Width - kItemPad * m_scale - width,
rect.Y + (rect.Height - height) * 0.5f, width, height);
}
gd::RectF PopupMenu::HeaderIconRect(int index) const
{
if (index < 0 || index >= static_cast<int>(m_items.size()))
{
return gd::RectF();
}
const MenuItem& item = m_items[static_cast<std::size_t>(index)];
if (item.kind != MenuItemKind::HEADER_ACTION)
{
return gd::RectF();
}
const gd::RectF& rect = m_itemRects[static_cast<std::size_t>(index)];
const float size = std::min(kHeaderIconSize * m_scale, std::max(6.0f, rect.Height - 4.0f * m_scale));
return gd::RectF(rect.X + rect.Width - kItemPad * m_scale - size,
rect.Y + (rect.Height - size) * 0.5f, size, size);
}
bool PopupMenu::IsOnEditField(int x, int y) const
{
if (m_editIndex < 0)
{
return false;
{ return false;
}
const gd::RectF rect = EditFieldRect(m_editIndex);
return UiPointInRect(rect, static_cast<float>(x), static_cast<float>(y));
@@ -340,8 +466,8 @@ namespace jdi
return false;
}
// 输入框初值取当前显示值,整段选中,便于直接键入新数值。
m_edit.Begin(item.value, true, item.filter, 0u);
// 输入框初值取「编辑初值」(未设置时用显示值),整段选中,便于直接键入新数值。
m_edit.Begin(item.editValue.empty() ? item.value : item.editValue, true, item.filter, 0u);
m_editIndex = index;
m_hoverIndex = index;
return true;
@@ -365,8 +491,10 @@ namespace jdi
}
MenuItem& item = m_items[static_cast<std::size_t>(index)];
// 立即回写显示值,使菜单上的数值与数据层一致(宿主会在派发后再纠正为夹取后的真实值)。
// 立即回写显示值,使菜单上的数值与数据层一致(宿主会在派发后再纠正为夹取后的真实值);
// 键入文本同时记为**编辑初值**(`value` 与 `editValue` 不一致的行也能正确回显上次输入)。
item.value = text;
item.editValue = text;
result.valid = true;
result.scope = m_scope;
@@ -391,17 +519,23 @@ namespace jdi
}
}
bool PopupMenu::UpdateItemValue(int id, const std::wstring& value)
bool PopupMenu::UpdateItemValue(int id, const std::wstring& value, const std::wstring& editValue)
{
for (MenuItem& item : m_items)
{
if (item.id == id && item.kind == MenuItemKind::EDIT)
{
if (item.value == value)
const bool valueSame = (item.value == value);
const bool editSame = editValue.empty() || (item.editValue == editValue);
if (valueSame && editSame)
{
return true;
}
item.value = value;
if (!editValue.empty())
{
item.editValue = editValue;
}
RebuildLayout();
return true;
}
@@ -509,6 +643,7 @@ namespace jdi
m_scope = scope;
m_open = true;
m_hoverIndex = -1;
m_hoverIconIndex = -1;
m_hoverSwatch = -1;
m_pressedIndex = -1;
m_pressedSwatch = -1;
@@ -529,6 +664,7 @@ namespace jdi
m_itemRects.clear();
m_swatchRects.clear();
m_hoverIndex = -1;
m_hoverIconIndex = -1;
m_hoverSwatch = -1;
m_pressedIndex = -1;
m_pressedSwatch = -1;
@@ -581,8 +717,7 @@ namespace jdi
}
if (!UiPointInRect(m_itemRects[index], fx, fy))
{
continue;
}
continue; }
if (item.kind == MenuItemKind::SWATCHES)
{
@@ -723,6 +858,29 @@ namespace jdi
changed = true;
}
// `HEADER_ACTION` 的图标按钮单独跟踪悬停状态(用于高亮,其余标题区域不响应)。
{
int iconHover = -1;
for (std::size_t itemIndex = 0; itemIndex < m_items.size(); ++itemIndex)
{
if (m_items[itemIndex].kind != MenuItemKind::HEADER_ACTION)
{
continue;
}
const gd::RectF icon = HeaderIconRect(static_cast<int>(itemIndex));
if (icon.Width > 0.0f && UiPointInRect(icon, fx, fy))
{
iconHover = static_cast<int>(itemIndex);
break;
}
}
if (iconHover != m_hoverIconIndex)
{
m_hoverIconIndex = iconHover;
changed = true;
}
}
// 悬停到带子菜单的项 → 向右展开;移开到其它条目或离开菜单 → 收起。
if (onSubmenuParent)
{
@@ -857,6 +1015,22 @@ namespace jdi
return true;
}
// `HEADER_ACTION`:只有**点在右侧图标按钮上**才下发命令(标题区域不响应);
// 菜单保持打开(这是一个可反复使用的小按钮,如「还原系数」)。
if (item.kind == MenuItemKind::HEADER_ACTION)
{
const gd::RectF icon = HeaderIconRect(index);
if (icon.Width > 0.0f && UiPointInRect(icon, static_cast<float>(x), static_cast<float>(y)))
{
result.valid = true;
result.scope = m_scope;
result.id = item.id;
result.swatchIndex = -1;
result.itemRect = icon;
}
return true;
}
// 编辑项:单击进入菜单内联编辑,菜单保持打开。
if (item.kind == MenuItemKind::EDIT)
{
@@ -880,6 +1054,12 @@ namespace jdi
result.swatchIndex = swatchIndex;
}
// 带复选框的切换项(如镜像的水平/垂直)选择后**保持菜单打开**,便于连续勾选。
if (item.keepOpen)
{
return true;
}
Close();
return true;
}
@@ -1043,6 +1223,29 @@ namespace jdi
continue;
}
if (item.kind == MenuItemKind::HEADER_ACTION)
{
// 标题 + 右侧「还原」图标按钮:文字与普通标题同一列;图标可点击(悬停高亮)。
const gd::RectF icon = HeaderIconRect(static_cast<int>(index));
const float labelWidth =
std::max(8.0f, icon.X - rect.X - kItemPad * m_scale - kHeaderIconGap * m_scale);
UiText(g, UiEllipsize(item.label, m_fonts->uiSmallBold, labelWidth), m_fonts->uiSmallBold,
gd::RectF(rect.X + kItemPad * m_scale, rect.Y, labelWidth, rect.Height),
m_theme->textMuted, TextAlign::LEFT);
if (icon.Width > 0.0f)
{
const bool iconHovered = (static_cast<int>(index) == m_hoverIconIndex);
if (iconHovered)
{
UiFillRoundRect(g, gd::RectF(icon.X - 2.0f * m_scale, icon.Y - 2.0f * m_scale,
icon.Width + 4.0f * m_scale, icon.Height + 4.0f * m_scale),
4.0f * m_scale, WithAlpha(m_theme->accent, 0x33));
}
PaintResetIcon(g, icon, iconHovered ? m_theme->accent : m_theme->text, m_scale);
}
continue;
}
const bool hovered = (static_cast<int>(index) == m_hoverIndex) &&
(item.kind != MenuItemKind::SWATCHES || m_hoverSwatch < 0);
@@ -1063,9 +1266,31 @@ namespace jdi
UiText(g, UiEllipsize(item.label, m_fonts->uiSmall, labelWidth), m_fonts->uiSmall,
gd::RectF(labelLeft, rect.Y, labelWidth, rect.Height), labelColor, TextAlign::LEFT);
const float suffix = EditSuffixWidth(item);
if (static_cast<int>(index) == m_editIndex)
{
UiDrawEditField(g, fieldRect, m_edit, m_fonts->uiSmall, m_scale);
// 编辑态:输入框底板覆盖整行,正文区向右让出后缀宽度,
// 后缀(单位提示)靠右对齐画在它右侧。
// ⚠️ 必须用 `UiDrawEditFieldContent`(只画内容)——`UiDrawEditField`
// 会把底板/描边按传入矩形绘制,那样带后缀时外框会短一截。
const gd::RectF textRect =
(suffix > 0.0f)
? gd::RectF(fieldRect.X, fieldRect.Y, std::max(12.0f, fieldRect.Width - suffix),
fieldRect.Height)
: fieldRect;
UiFillRoundRect(g, fieldRect, 3.0f * m_scale, m_theme->cardBg);
UiDrawRoundRect(g, fieldRect, 3.0f * m_scale, m_theme->accent, 1.6f);
UiDrawEditFieldContent(g, textRect, m_edit, m_fonts->uiSmall, m_scale);
if (suffix > 0.0f)
{
const float suffixLeft = textRect.X + textRect.Width;
UiText(g, item.editSuffix, m_fonts->uiSmall,
gd::RectF(suffixLeft, fieldRect.Y,
std::max(4.0f, fieldRect.X + fieldRect.Width - suffixLeft -
4.0f * m_scale),
fieldRect.Height),
m_theme->textMuted, TextAlign::RIGHT);
}
}
else
{
+133 -2
View File
@@ -24,6 +24,13 @@ namespace jdi
{
return c >= L'A' && c <= L'Z';
}
/** @brief 时间间隔的显示单位。 */
struct DurationUnit
{
double scale = 1.0; /**< 1 个该单位等于多少秒。 */
const wchar_t* name = L"s"; /**< 单位名称。 */
};
}
std::wstring Utf8ToWide(const char* text, int length)
@@ -339,9 +346,133 @@ namespace jdi
return std::wstring(buffer);
}
std::wstring FormatByteSize(unsigned long long bytes)
std::wstring FormatDurationCompound(double seconds)
{
// 单位自适应:GB / MB / KB(阈值按 1024 进制)。
if (!std::isfinite(seconds))
{
return L"—";
}
const double value = std::fabs(seconds);
if (value <= 0.0)
{
return L"0s";
}
// ---- 顶级单位高于 s(h / min):整合为 h/min/s(整数秒)----
// 注意条件必须是 >= 60s:1~60s 属于"顶级单位为 s"的情况,需按下一条规则处理
// (非整数秒要递进到 ms,例如 5.06s → "5s 60ms")。
if (value >= 60.0)
{
const long long totalSeconds = static_cast<long long>(std::llround(value));
long long hours = 0;
long long minutes = 0;
long long secs = totalSeconds;
if (totalSeconds >= 3600)
{
hours = totalSeconds / 3600;
secs = totalSeconds % 3600;
}
if (secs >= 60)
{
minutes = secs / 60;
secs = secs % 60;
}
std::wstring text;
const auto append = [&text](long long amount, const wchar_t* unit)
{
if (amount <= 0)
{
return;
}
if (!text.empty())
{
text += L" ";
}
text += std::to_wstring(amount);
text += unit;
};
append(hours, L"h");
append(minutes, L"min");
append(secs, L"s");
if (!text.empty())
{
return text;
}
}
// ---- 顶级单位为 s:整数秒 + 可选 ms ----
if (value >= 1.0)
{
const double whole = std::floor(value);
const double fraction = value - whole;
std::wstring text = FormatValue(whole) + L"s";
const double remainderMs = fraction * 1000.0;
if (remainderMs >= 1.0e-6)
{
text += L" ";
text += FormatValue(remainderMs);
text += L"ms";
}
return text;
}
// ---- 顶级单位低于 s:ms / us / ns / ps ----
const DurationUnit subUnits[] = {
{1.0e-3, L"ms"}, {1.0e-6, L"us"}, {1.0e-9, L"ns"}, {1.0e-12, L"ps"},
};
const std::size_t subCount = sizeof(subUnits) / sizeof(subUnits[0]);
std::size_t index = subCount - 1;
for (std::size_t i = 0; i < subCount; ++i)
{
if (value >= subUnits[i].scale)
{
index = i;
break;
}
}
// 顶级单位为 ps:不再递进次级单位(保留小数)。
if (index + 1 >= subCount)
{
return FormatValue(value / subUnits[index].scale) + L" " + subUnits[index].name;
}
const double scaled = value / subUnits[index].scale;
double whole = std::floor(scaled);
double fraction = scaled - whole;
// 浮点误差可能导致 fraction 极接近 1(例如 4.9999999),归一化一下。
if (fraction >= 1.0 - 1.0e-9)
{
whole += 1.0;
fraction = 0.0;
}
std::wstring text = FormatValue(whole);
text += subUnits[index].name;
// 仍是该单位的整数 → 不显示下级单位。
const double remainder = fraction * subUnits[index].scale;
const double inNext = remainder / subUnits[index + 1].scale;
if (inNext >= 1.0e-6)
{
text += L" ";
text += FormatValue(inNext);
text += subUnits[index + 1].name;
}
return text;
}
std::wstring FormatDurationSeconds(double seconds)
{
if (!std::isfinite(seconds))
{
return L"0";
}
return FormatValue(std::fabs(seconds));
}
std::wstring FormatByteSize(unsigned long long bytes)
{ // 单位自适应:GB / MB / KB(阈值按 1024 进制)。
constexpr double kKilo = 1024.0;
constexpr double kMega = kKilo * 1024.0;
constexpr double kGiga = kMega * 1024.0;