Compare commits

..
7 Commits
Author SHA1 Message Date
DeepSeek V4.1 Flash 328a3b3b59 fix(ui): 选区截取面板空白真正根因、拖拽指示线层序、新建分组修复、避让动画更顺
一、选区截取面板「条目区与按钮之间的空白」(连续三轮未修好的那个)
根因不是内容区几何,而是**底部固定区高度算了两行**:footerHeight 用
`buttonHeight * 2 + 6 + pad` 预留,而布局把「截取为新信号 / 原始信号截取」
两个按钮并排画在同一行 ⇒ 内容区下边界与按钮上边界之间空出一整行。
改为按一行算(两处:高度算式分支 + 布局几何分支同步修改)。
数值核对(scale=1.25、1 条可见信号):GAP 53.8px → 8.8px(= 一个 pad 的预期留白)。

二、拖拽插入指示线不再盖住被拖条目
PaintSignalDragGhost 的绘制顺序改为「插入指示线 → 阴影 → 幽灵行 → 描边」,
指示线始终位于跟随鼠标的那部分之下。

三、「移动到…」二级菜单
* 条目文案改为「组号 | 组名」(不再是括号形式)。
* 修复「新建分组」点了没反应:DataSet::AddGroup 拒收「无信号且无时间轴」的空分组,
  旧实现「先建空组再搬信号」必然在建组那一步失败。新增
  DataSet::MoveSignalToNewGroup 把建组 + 搬运时间轴 + 搬运信号做成原子操作(失败回滚)。
* TabConfig::RemapAfterSignalMove 在目标分组是新建时自行扩展 m_visible
  (调用方不再先 SyncToDataSet —— 那次同步会按旧下标清理引用)。
* Application::MoveSignalCore 增加 newGroupName 参数并返回 bool,避免失败仍报"已移动"。

四、信号拖拽避让动画更流畅
* 插值系数按真实帧间隔计算:1 - exp(-dt/22ms),dt 夹在 1~60ms,帧间隔抖动时不再忽快忽慢。
* 鼠标移动事件里也推进一次插值,快速拖动时让位效果跟得上指针。

验证:删 .o 全量重编译零 error / 零 warning,产物 875,008 B(< 1MB 预警阈值)。
本轮未占用鼠标/键盘/屏幕,未运行任何 GUI 探针;面板空白改为纯数值算式核对。
规则已同步到 Documents/agent_mem.md(含"改面板几何必须数值验证"的教训条目)。
2026-11-03 20:09:01 +08:00
DeepSeek V4.1 Flash 97b361dedd fix(ui): 采样统计显示一致化、GX 拖拽在布局边界下可用、信号「移动到…」与拖拽排序
一、采样间隔/频率显示不一致(用户报告 1ms vs 1000us、100us 0ns)
* FormatDurationCompound 改为「先量化到整数皮秒、再整数分解」,
  彻底消除同一数值因浮点噪声落到不同单位/留下零尾数的问题;
  秒级分支与亚秒分支共用 totalPs 方案,次级进位/单位升级集中在一处。
* FormatFrequency 的单位选择改为按「舍入后的显示值」判定(999.9999999Hz
  与 1000Hz 均为 1.000kHz),并做向下校正避免 0.001kHz。

二、选区截取面板
* 提示文本改为用户指定原文「启用双光标模式以进行选区截取」。
* EdgePanelWidth 计入该提示的实际文本宽度;绘制改用 UiTextClipped 不再省略。
* viewHeight 补回漏减的 bottomPad,修掉条目区与按钮之间的空白。

三、未展开的信号条目栏悬停不再变成手型光标。

四、开启「显示布局边界」后 GX 标签可正常拖拽排序
* 抽出 PaintLaneReorderOverlay(吸附槽位 + 被拖轮廓),贴图与整幅重绘共用。
* 整幅重绘在 LANE_REORDER 时按 LaneDragOffset 逐格平移重画(被拖网格最后画),
  修掉调试模式下不跟随鼠标、无吸附提示、只能盲拖的问题。

五、信号右键新增「移动到…」二级菜单(位于「仅显示该信号」之上)
* 条目为「分组 N(组名)」,当前组打勾;无标题行;底部「新建分组」。
* MenuItem::submenuMaxRows + PopupMenu 独立子菜单滚动(滚动条、滚轮、视口命中过滤),
  IsScrollable 计入子菜单可滚状态。
* 数据层 DataSet::MoveSignalToGroup/MoveSignal 让时间轴随信号搬运
  (独占则移动、共享则复制);TabConfig::RemapAfterSignalMove 改写引用而非先删后插。
* Application::MoveSignalCore 统一入口,保证派生计算暂停、引用修正、选中迁移与同步一致。

六、信号列表支持按住拖拽排序(组内重排 / 跨组移动)
* 4px 阈值进入拖拽;条目脱离原位跟随鼠标(阴影 + 强调色描边 + 插入指示线)。
* 其它信号行/分组行按插入位置做避让位移动画(RowRect 统一叠加插值位移,
  宿主 kScrollTimerId 驱动 AdvanceSignalDragAnimation)。
* 数据层 DataSet::MoveSignal 支持任意位置;TabConfig::RemapAfterSignalReorder 线性重映射。
* 拖拽中禁用组名悬浮;双击改名与 InvalidateRows 均正确终止拖拽态。

验证:删 .o 全量重编译零 error / 零 warning;产物 873,984 B(< 1MB 预警阈值)。
本轮未使用鼠标/键盘/屏幕,未运行任何 GUI 探针,仅编译验证 + 源码级核对。
2026-11-03 19:27:43 +08:00
DeepSeek V4.1 Flash 21a9865365 fix(clip): 选区截取面板五处缺陷(条目/空白/多信号截取/滚轮)
一、未启用双光标时不再显示信号条目
   EdgePanelLayoutFor 与绘制侧都只判 clipPanel、没判双光标是否启用,
   于是照常累加内容高度、生成行矩形并绘制条目。
   - 高度算式新增 clipReady = clipPanel && CursorMode()==DOUBLE,未就绪时内容只占一行(提示);
   - 行几何循环加 clipRowsVisible,未就绪时不生成任何行矩形 => 绘制自然为空;
   - footer 同步改为"未就绪 = 0"。

二、条目区与底部按钮之间的大段空白(未启用双光标时也有)
   两个叠加根因:
   1) 高度算式的 maxContent 有"至少 3 行"下限 => 只有 1 个信号也硬撑 3 行高;
      改为 min(上限, max(1 行, budget))。
   2) 几何侧 viewHeight 多减了一个 pad(高度算式只含一个 pad + bottomPad);
      统一为 height - titleHeight - pad - footerHeight。
   高度算式与内容区几何必须严格同源。

三、多个信号「原始信号截取」结果不对
   同组信号共享同一条时间轴,ClipSignalRange 每次都裁剪 axis.time
   => 截完第一条后其它信号时间基准被改短 => 采样与时间错位。
   - 新增 DataSet::ForkAxisForSignal:轴非独占时复制一份新轴并让信号指向它;
   - ClipSignalRange 内再加一道守卫:轴仍被共享时直接返回 false(不改任何数据);
   - Application::ClipSignalInPlace 先 fork 再裁剪。

四、有滚动条时滚轮滚不到底
   1) OnMouseWheel 的 current 取值链漏了 CLIP => 读到 meanFilterScroll(恒 0)而写入 clipScroll,
      补上 CLIP 分支;
   2) PanelSlotKey 里 clipScroll 被写成布尔(>0 ? 1 : 0)=> 滚到 1px 后键不再变化,
      改为 mixFloat(lane.clipScroll) 真实值。

删 .o 全量重编译:零 error、零 warning,产物 861,184 B。
按用户要求未占用鼠标/键盘/屏幕,未运行 GUI 探针。
2026-11-03 16:39:18 +08:00
DeepSeek V4.1 Flash 62220477c0 feat(clip): 新增「选区截取」实验性功能 + 筛选态组复选框只作用于筛选结果 + 重采样统计不被省略
一、重采样面板统计信息不再被省略为 `…`
   - 根因:EdgePanelWidth 里数值列按"典型样例"(1h 1min 2s(1.23456 GHz))估宽,
     真实文本(16ms 182.614us(61.795 Hz))更长 => 绘制矩形不够宽 => 尾部被裁。
   - 新增 FormatIntervalWithFrequency() 作为「间隔(频率)」文本的唯一实现,
     宽度计算与绘制共用它;EdgePanelWidth 改为逐信号实测真实文本宽度取最大值。

二、筛选状态下组复选框只作用于「筛选出来的信号」
   - TabConfig 新增 SetGroupVisibleSubset / GroupAllVisibleSubset / GroupHasVisibleSubset,
     只读写掩码内元素,未通过筛选的信号可见性保持不变。
   - SignalPanel::BuildFilterMask 提供逐信号掩码(未筛选时返回 false,走整组语义)。
   - 组复选框的点击、绘制、行位图缓存键三处统一改用子集判定
     (只改绘制不改缓存键会导致勾选后复选框不刷新)。

三、新增实验性功能「选区截取」(EdgePanelKind::CLIP = 6,PLOT_CMD_LANE_CLIP = 14)
   - 菜单位置:实验性功能二级菜单,「重采样」之下、「自由标记」上方,带勾选。
   - 面板:逐信号一行(信号色叠底条目栏 + 右侧复选框,不可展开),内容可滚动;
     底部两个不滚动的按钮「截取为新信号」与「原始信号截取」;
     未启用双光标时两个按钮为空矩形并给出提示。
   - 截取为新信号:区间内原始数据(不烘焙)+ 继承源信号整套调整,
     名称为 <源信号名> (AAAA - BBBB)(显示时刻、保留三位小数、带 s),插入源信号下方,
     使用 AppendGroupAxis 新建的独立时间轴。
   - 原始信号截取:DataSet::ClipSignalRange 就地裁剪时间轴与采样,
     用新建+swap+shrink_to_fit 真正归还内存,并重算统计与轴的 minTime/maxTime/monotonic。
     调用前先 PauseDerivedAsync() 避免后台派生读到已释放采样。
   - 时间轴偏移参与区间比较:双光标是显示时刻(time+offsetTime),
     BuildClippedSignal 比较时加偏移,ClipSignalInPlace 换算回采样时刻(low-offsetTime)。
   - 同步点已全部补齐:LaneCarry(三处)/SplitLane/PruneLaneSignalItems/RemapAfterSignalInsert/
     purgeRefs/PanelSlotKey/CollectDebugBoxes/SceneContentHash/kEdgePanelKinds/kOrder/
     EdgePanelLayoutFor/EDGE_PANEL_MOVE/m_pendingPanelClose/MEAN_SCROLL/滚轮/悬停/
     SetLanePanelCollapsed/LanePanelCollapsed/QueryCursor。

make release 零 warning,产物 860,160 B。
2026-11-03 16:04:48 +08:00
DeepSeek V4.1 Flash 5d305cb10f chore: remove accidentally committed Temp/msg.txt 2026-11-03 14:16:30 +08:00
DeepSeek V4.1 Flash c4300bd0e1 fix(resample): 重采样间隔/频率按真实值显示(次级单位与频率保留三位小数)
问题(用户实测 MF4-Record.MF4 / m_MOT_Current_DCom[0]):
- 最小数据间隔真实值 0.003696631s,却被显示成 3ms 700us(us 部分被向上截取);
- 对应频率真实值 270.516586589Hz,却被显示成 270.27Hz;
- 最大数据间隔 0.016182614s 显示成 16ms 200us、频率显示成 61.7284Hz,同样不准。

根因(两个,同一个坑):
1. RoundIntervalForDisplay() 把间隔先舍入到 3 位有效数字(0.003696631 -> 0.0037),
   次级单位因此变成 700us,真实尾数被抹掉;
2. 频率用舍入后的 3.70ms 换算得 270.27Hz,而输入框用原值得 270.517Hz,两处不一致。

修法(一律使用真实值,舍入只发生在输出文本的最后一步):
- Utf8Util 新增 FormatDecimalFixed(value, decimals=3):固定小数位、保留尾随 0、
  绝不出现科学计数法;
- Utf8Util 新增 FormatSubUnit(value, maxDecimals):带小数才补到 3 位,整数不加小数点
  (5.06s -> 5s 60ms,而不是 5s 60.000ms);
- FormatDurationCompound 的两处次级单位(s+ms、ms/us/ns)改用它;
- FormatFrequency 改为 FormatDecimalFixed(scaled, 3);若保留 3 位后变成 0.000
  (超低频,如 1/3662s ≈ 0.27mHz)则退回 FormatDecimal,避免有意义的值显示成 0;
- PlotViewInternal.hpp 删除 RoundIntervalForDisplay,FormatIntervalText 直接用真实值;
- PlotViewPaint.cpp 的统计行尾缀与频率输入框都用真实 interval 换算。

修复后的显示:
  min interval 0.003696631s -> 3ms 696.631us
  frequency   1/0.003696631 -> 270.517 Hz
  max interval 0.016182614s -> 16ms 182.614us
  frequency   1/0.016182614 -> 61.795 Hz

同时撤销了此前两次仅修改 agent_mem.md 的提交(47b1213 / caa035a,内容为乱码且无有效信息),
并按正确编码重新整理本轮记忆章节。

面板仍为四行(最大间隔 / 最小间隔 / 重采样间隔 / 重采样频率),未改动任何 UI 布局。
make release 零 warning。
2026-11-03 14:16:25 +08:00
DeepSeek V4.1 Flash 9fdbf49841 fix(resample,formula,clone): 重采样面板拖动/关闭失效 + 统计频率与输入框不一致 + 派生烘焙接入公式映射 + 克隆拆分
一、「重采样」浮动面板拖不动 / ✕ 关不掉(两个独立缺陷)
1. 拖不动:OnLButtonDown 的 resampleMode 分支处理完条目与按钮后无条件 return true,
   而「标题栏 -> EDGE_PANEL_MOVE」的判定在面板分支最末尾 => 标题栏按下被吞掉。
   改为只消费 insideContent 之内的点击。
2. 拖不动(第二处):OnMouseMove 的 EDGE_PANEL_MOVE -> switch (m_edgePanelDragKind)
   漏了 case RESAMPLE => 位置写进 edgePanel、又从 resamplePanel 读 => 纹丝不动。
3. ✕ 关不掉:OnLButtonUp 的 m_pendingPanelClose -> switch (kind) 漏了 RESAMPLE
   => 落进 default 关掉的是「边沿计数」。补 SetLaneResample(lane,false)。

二、统计「最小数据间隔」与「重采样频率」输入框不一致(用户报告)
   实例 MF4-Record.MF4 / m_MOT_Current_DCom[0]:最小间隔 0.003696631s
   - 统计行先舍入到 3 位有效数字(3.70ms)再换算 => 270.27 Hz;
   - 输入框用未舍入原值换算 => 270.517 Hz。
   真值 270.5166 Hz,两条路径基准不同。修法:频率一律由 RoundIntervalForDisplay
   之后的同一份显示值换算 => 输入框与统计行同为 270.27 Hz、间隔文本同为 3ms 700us。
   未改动面板行数与任何 UI 布局。

三、派生烘焙接入「公式映射」
   BuildDerivedSignal 与 CloneSignal 各写了一份
   gain!=1 || absolute || mirrorVertical || offsetY!=0,漏掉 HasFormula()
   => 带公式的信号派生/克隆后公式被静默丢弃。改为含 HasFormula() 的统一判定。
   BuildResampledSignal 本就走 ApplyDrawTransform 全程烘值,天然包含公式。

四、信号右键菜单:克隆拆成两个语义(均不烘焙采样值)
   - 「克隆该信号」:原始数据 + 外观 + 仅保留时间轴偏移,其余调整复位;
   - 「克隆该信号(带调整)」:原始数据 + 外观 + 整套调整参数。
   新增 Application::CloneSignalAdjusted、CMD_SIGNAL_CLONE_ADJUSTED=6、
   kMessageCloneSignalAdjusted=WM_APP+6。

五、教训:离屏探针崩溃会经 MemProbe::OnTerminate 弹模态「内存不足」框,
   反复重跑即反复弹窗(用户看到十几次报警,真实进程并无内存问题)。
   探针应避免 Initialize+Render,或只做数据层验证;必须无缓冲输出阶段标记。

验证:.preflight/r26 三个探针(真实 MF4 数据一致性 SAME;逻辑 7/7;端到端 12/12);
      make release 零 warning。
2026-11-03 11:09:25 +08:00
27 changed files with 3680 additions and 182 deletions

No files matched your search

+108 -4
View File
@@ -224,6 +224,54 @@ namespace jdi
*/
void RemoveSignal(int groupIndex, int signalIndex);
/**
* @brief 把一条信号移动到另一个分组(`toGroup < 0` 表示新建分组)。
*
* 用户从信号右键菜单的「移动到…」调用。跨分组移动会让 `SignalData::axisIndex`
* 失效(轴按分组存放),因此数据层会把该信号的时间轴一并带走
* (独占则移动、共享则复制),详见 `DataSet::MoveSignalToGroup`。
*
* 移动后:各标签页先 `RemapAfterSignalMove` 修正引用(信号**留在原网格**、
* 曲线继续显示),共享选中项同步改写,最后 `SyncTabsToDataSet` 整体对齐。
*
* @param [IN] fromGroup 源分组下标。
* @param [IN] fromSignal 源分组内的信号下标。
* @param [IN] toGroup 目标分组下标,负数表示新建一个名为「新建分组」的分组。
*/
void MoveSignalToGroup(int fromGroup, int fromSignal, int toGroup);
/**
* @brief 生成一个在现有分组中不重复的分组名(`base` / `base (2)` / `base (3)` …)。
* @param [IN] base 期望名称。
* @return 实际可用名称。
*/
std::wstring UniqueGroupName(const std::wstring& base) const;
/**
* @brief 把一条信号移动到指定分组的指定位置(组内重排 / 跨组移动的统一实现)。
*
* `MoveSignalToGroup` 与信号列表的拖拽排序都汇到这里,保证两处的引用修正、
* 选中项迁移与同步流程完全一致。
*
* @param [IN] fromGroup 源分组下标。
* @param [IN] fromSignal 源分组内的信号下标。
* @param [IN] toGroup 目标分组下标(负数 + `newGroupName` 非空 = 新建分组)。
* @param [IN] toIndex 目标组内下标(负数表示追加到末尾)。
* @param [IN] newGroupName 非空且 `toGroup < 0` 时,新建该名称的分组并把信号搬进去。
* @return 确实发生了移动返回 true。
*/
bool MoveSignalCore(int fromGroup, int fromSignal, int toGroup, int toIndex,
const std::wstring* newGroupName);
/**
* @brief 信号列表拖拽排序的提交入口(组内重排或跨组移动)。
* @param [IN] fromGroup 源分组下标。
* @param [IN] fromSignal 源分组内的信号下标。
* @param [IN] toGroup 目标分组下标。
* @param [IN] toIndex 目标组内下标(-1 表示末尾)。
*/
void ReorderSignal(int fromGroup, int fromSignal, int toGroup, int toIndex);
/**
* @brief 处理标签栏交互结果。
* @param [IN] result 交互结果。
@@ -337,6 +385,41 @@ namespace jdi
*/
bool BuildResampledSignal(int groupIndex, int signalIndex, double interval);
/**
* @brief 「选区截取 → 截取为新信号」:按 `[begin, end]` 区间截取出**新信号**。
*
* 语义(用户 2026-11-03 明确要求):
* * 采样值 = 源信号在该区间内的**原始数据**(不烘焙任何调整);
* * 名称 = `<源信号名> (AAAA - BBBB)`,其中 AAAA / BBBB 是选区左右端时刻,
* 以秒为单位、**保留三位小数**并带 `s` 单位(如 `(1.234s - 2.345s)`);
* * 插入到源信号下方,并**继承源信号的整套调整**(公式/系数/偏移/镜像/绝对值)。
*
* @param [IN] groupIndex 源信号所属分组下标。
* @param [IN] signalIndex 源信号下标。
* @param [IN] begin 选区左端(显示时刻,秒)。
* @param [IN] end 选区右端(显示时刻,秒)。
* @return 构建并插入成功返回 true。
*/
bool BuildClippedSignal(int groupIndex, int signalIndex, double begin, double end);
/**
* @brief 「选区截取 → 原始信号截取」:**破坏性**丢弃 `[begin, end]` 之外的采样。
*
* 与 `BuildClippedSignal` 不同,这里直接改写源信号的采样数据与所属时间轴:
* * 保留区间内的采样,区间外的采样与对应时间全部丢弃并释放(`shrink_to_fit`);
* * 名称不变,也不新增信号;
* * 重算统计(min/max/finiteCount/粗粒度包络)。
*
* ⚠️ 该操作**不可撤销**,因此面板按钮语义上等价于"就地截断原始信号"。
*
* @param [IN] groupIndex 分组下标。
* @param [IN] signalIndex 信号下标。
* @param [IN] begin 选区左端(显示时刻,秒)。
* @param [IN] end 选区右端(显示时刻,秒)。
* @return 成功返回 true。
*/
bool ClipSignalInPlace(int groupIndex, int signalIndex, double begin, double end);
/**
* @brief 把一条信号插入到「源信号」的下方,并同步各标签页的引用与可见性。
*
@@ -355,17 +438,38 @@ namespace jdi
bool makeVisible, int& newIndex);
/**
* @brief 克隆一条信号:把它的「调整」(系数 / 绝对值 / 镜像 / Y 偏移)烘焙进采样值,
* 时间轴偏移保留在样式上,名称前置 `*`,插入到原信号下方。
* @brief 克隆一条信号(**不烘值**,只沿用源信号的时间轴偏移)。
*
* 烘值规则与「构建为新信号」完全一致,因此克隆曲线与原信号重合;区别只是
* 克隆**保留原外观**(颜色 / 透明度 / 线宽 / 绘制样式),便于“冻住当前效果继续调”。
* 语义(用户 2026-11-03 明确要求):
* * 采样值 = 源信号的**原始数据**(不做任何烘焙);
* * 名称 = `*` + 源信号名,插入到原信号下方;
* * 样式:沿用源信号的外观(颜色 / 透明度 / 线宽 / 绘制样式)**与时间轴偏移**
* (`offsetTime` 只能留在样式上,无法烘进采样值),
* 而**公式映射 / 系数 / 绝对值 / 镜像 / Y 偏移一律恢复默认**。
*
* 于是「克隆该信号」得到的是一条“去掉所有数值调整、但仍在同一个时刻显示”的原始信号,
* 便于与源信号叠加对比。
*
* @param [IN] sourceRef 源信号引用。
* @return 克隆成功返回 true。
*/
bool CloneSignal(const SignalRef& sourceRef);
/**
* @brief 克隆一条信号(**带调整**:完整沿用源信号的调整参数,不烘值)。
*
* 语义(用户 2026-11-03 明确要求):
* * 采样值 = 源信号的**原始数据**(不做任何烘焙);
* * 名称 = `*` + 源信号名,插入到原信号下方;
* * 样式:**完整复制**源信号的调整参数(公式映射 / 系数 / Y 偏移 / 时间轴偏移 /
* 水平与垂直镜像 / 绝对值)以及外观(颜色 / 透明度 / 线宽 / 绘制样式)。
*
* 因此新信号与源信号**逐像素重合**,且可以在新信号上继续独立修改调整而不影响源信号。
*
* @param [IN] sourceRef 源信号引用。
* @return 克隆成功返回 true。
*/
bool CloneSignalAdjusted(const SignalRef& sourceRef);
/**
* @brief 弹出“打开数据文件”对话框并加载所选文件。
*/
@@ -64,6 +64,30 @@ namespace jdi
inline const UINT kMessageRemoveGroup = WM_APP + 1; /**< 面板请求移除分组的自定义消息。 */
inline const UINT kMessageRemoveSignal = WM_APP + 2; /**< 面板请求移除单个信号的自定义消息。 */
inline const UINT kMessageCloneSignal = WM_APP + 5; /**< 面板请求克隆单个信号的自定义消息。 */
/**
* @brief 面板请求「克隆该信号(带调整)」的自定义消息。
*
* `wParam = groupIndex`、`lParam = signalIndex`(与 `kMessageCloneSignal` 同一参数约定)。
* 新信号**沿用源信号的全部调整参数**(公式映射 / 系数 / 偏移 / 镜像 / 绝对值),
* 不烘值 ⇒ 两条曲线逐像素重合,且新信号的「调整」面板与源信号完全一致。
*/
inline const UINT kMessageCloneSignalAdjusted = WM_APP + 6; /**< 面板请求「克隆该信号(带调整)」的自定义消息。 */
/**
* @brief 面板请求「把信号移动到另一个分组」的自定义消息。
*
* `wParam = 源分组下标`;`lParam` 低 16 位 = 目标分组下标(`0xFFFF` 表示**新建分组**),
* 高 16 位 = 源分组内的信号下标。
* 新分组名固定为「新建分组」(重名时自动加序号),见 `Application::MoveSignalToGroup`。
*/
inline const UINT kMessageMoveSignalToGroup = WM_APP + 7;
inline const int kMoveSignalNewGroupMarker = 0xFFFF; /**< `kMessageMoveSignalToGroup` 中「新建分组」的 lParam 标记。 */
/**
* @brief 面板请求「信号列表拖拽排序」的自定义消息。
*
* `wParam` = `(源分组下标 << 16) | 源组内信号下标`;
* `lParam` 低 16 位 = 目标分组下标,高 16 位 = 目标组内下标(`0xFFFF` 表示末尾)。
*/
inline const UINT kMessageReorderSignal = WM_APP + 8;
inline const UINT_PTR kHintTimerId = 1; /**< 提示浮层淡出用的定时器 ID。 */
inline const UINT kHintTimerPeriodMs = 33; /**< 提示浮层淡出的重绘间隔(约 30fps)。 */
inline const UINT_PTR kRamTimerId = 2; /**< 标题栏内存占用刷新的定时器 ID。 */
+44 -3
View File
@@ -506,7 +506,9 @@ namespace jdi
// 不含面板(例如与鼠标消息或 `kMessagePlotDerivedReady` 的失效区域交错),
// 那一帧就**完全不重画面板** ⇒ 用户报告“过渡动画有时直接没了”。
// ✓ 把动画状态直接纳入重绘条件(**只增加重绘条件,不改变任何绘制/回送路径**)。
const bool panelAnimating = m_panel.ScrollAnimationActive() || m_panel.GroupAnimationActive();
const bool panelAnimating = m_panel.ScrollAnimationActive() ||
m_panel.GroupAnimationActive() ||
m_panel.SignalDragAnimationActive();
const bool overlaysVisible = m_menu.IsOpen() || m_colorPicker.IsOpen();
// ⚠️ 两部分分开:
// * `fullChrome` —— 标签栏 / 工具栏 / 整窗底色 / 面板都要重绘(有真内容变化);
@@ -668,6 +670,7 @@ namespace jdi
// 永远看不到最终数字。`PanelRefreshPending()` 为真时保持定时器,等
// `kEdgeScanInteractiveIdleMs` 过去、某个非交互帧把面板按最新数字重画一次。
if (m_panel.ScrollAnimationActive() || m_panel.GroupAnimationActive() ||
m_panel.SignalDragAnimationActive() ||
m_plot.LaneScrollAnimationActive() || m_plot.BackToTopAnimationActive() ||
m_plot.PanelRefreshPending())
{
@@ -1746,6 +1749,41 @@ namespace jdi
CloneSignal(ref);
return 0;
}
case kMessageCloneSignalAdjusted:
{
// 「克隆该信号(带调整)」:参数约定与 `kMessageCloneSignal` 完全相同。
SignalRef ref;
ref.groupIndex = static_cast<int>(wParam);
ref.signalIndex = static_cast<int>(static_cast<LONG_PTR>(lParam));
CloneSignalAdjusted(ref);
return 0;
}
case kMessageMoveSignalToGroup:
{
// wParam = 源分组下标;lParam 低 16 位 = 目标分组下标(0xFFFF = 新建分组),
// 高 16 位 = 源分组内的信号下标。
const LONG_PTR packed = static_cast<LONG_PTR>(lParam);
const int fromGroup = static_cast<int>(wParam);
const int fromSignal = static_cast<int>(packed >> 16);
const int targetRaw = static_cast<int>(packed & 0xFFFF);
MoveSignalToGroup(fromGroup, fromSignal,
(targetRaw == kMoveSignalNewGroupMarker) ? -1 : targetRaw);
return 0;
}
case kMessageReorderSignal:
{
// wParam = (源分组 << 16) | 源信号下标;
// lParam = (目标下标 << 16) | 目标分组(目标下标 0xFFFF = 末尾)。
const LONG_PTR from = static_cast<LONG_PTR>(wParam);
const LONG_PTR to = static_cast<LONG_PTR>(lParam);
const int fromGroup = static_cast<int>(from >> 16);
const int fromSignal = static_cast<int>(from & 0xFFFF);
const int toGroup = static_cast<int>(to & 0xFFFF);
const int toRaw = static_cast<int>(to >> 16);
ReorderSignal(fromGroup, fromSignal, toGroup,
(toRaw == kMoveSignalNewGroupMarker) ? -1 : toRaw);
return 0;
}
case WM_TIMER:
{
// 标题栏内存占用:只改窗口标题,不动客户区,因此无需重绘。
@@ -1776,14 +1814,17 @@ namespace jdi
// 保证面板的最终位置被完整重绘并回送。
if (wParam == kScrollTimerId)
{
const bool panelAnimating =
m_panel.ScrollAnimationActive() || m_panel.GroupAnimationActive();
const bool panelAnimating = m_panel.ScrollAnimationActive() ||
m_panel.GroupAnimationActive() ||
m_panel.SignalDragAnimationActive();
// 绘图区自己的动画(网格滚动到可视区 / 「回到最上方」淡入淡出)也会改像素:
// 它跟面板动画同时跑时,本帧**不能**跳过绘图区重绘(否则绘图区动画会冻结)。
const bool plotAnimating =
m_plot.LaneScrollAnimationActive() || m_plot.BackToTopAnimationActive();
m_panel.AdvanceScrollAnimation();
m_panel.AdvanceGroupAnimations();
// 信号条目拖拽:幽灵行跟随鼠标 + 其它行的避让插值(用户 2026-11-03 要求)。
m_panel.AdvanceSignalDragAnimation();
m_plot.AdvanceLaneScrollAnimation();
m_plot.AdvanceBackToTopAnimation();
if (panelAnimating)
@@ -244,6 +244,206 @@ namespace jdi
SetStatusMessage(L"已从工作区移除信号:" + name, false);
}
std::wstring Application::UniqueGroupName(const std::wstring& base) const
{
const auto taken = [this](const std::wstring& candidate)
{
for (const SignalGroup& group : m_dataSet.Groups())
{
if (group.name == candidate)
{
return true;
}
}
return false;
};
if (!taken(base))
{
return base;
}
for (int suffix = 2; suffix < 1000; ++suffix)
{
const std::wstring candidate = base + L" (" + std::to_wstring(suffix) + L")";
if (!taken(candidate))
{
return candidate;
}
}
return base;
}
bool Application::MoveSignalCore(int fromGroup, int fromSignal, int toGroup, int toIndex,
const std::wstring* newGroupName)
{
const SignalGroup* sourceGroup = m_dataSet.GroupAt(fromGroup);
if (sourceGroup == nullptr || fromSignal < 0 ||
static_cast<std::size_t>(fromSignal) >= sourceGroup->signals.size())
{
return false;
}
// 「移动到全新分组」:目标分组尚不存在,它会被追加到分组列表末尾
//(下标 = 当前分组数,可在此之前预先算出,供引用改写使用)。
const bool createGroup = (toGroup < 0) && (newGroupName != nullptr);
const int resolvedTo = createGroup ? static_cast<int>(m_dataSet.GroupCount()) : toGroup;
const SignalGroup* targetGroup = createGroup ? nullptr : m_dataSet.GroupAt(resolvedTo);
if (!createGroup && targetGroup == nullptr)
{
return false;
}
const bool sameGroup = !createGroup && (fromGroup == resolvedTo);
// 目标下标:负数/越界表示追加到末尾(组内重排时"末尾"= 组内信号数-1,因为该信号会先被摘出)。
int position = toIndex;
const int targetCount = createGroup ? 0 : static_cast<int>(targetGroup->signals.size());
if (createGroup)
{
position = 0;
}
else if (sameGroup)
{
if (position < 0 || position >= targetCount)
{
position = targetCount - 1;
}
if (position == fromSignal)
{
return false;
}
}
else if (position < 0 || position > targetCount)
{
position = targetCount;
}
SignalRef from;
from.groupIndex = fromGroup;
from.signalIndex = fromSignal;
SignalRef to;
to.groupIndex = resolvedTo;
to.signalIndex = position;
// 移动会搬运大量采样缓冲:先暂停后台派生曲线计算,避免其读取正在被移动的内存。
m_plot.PauseDerivedAsync();
// 先按**旧下标**修正各标签页内的全部信号引用,再动数据层。
// ⚠️ 跨组(含新建分组)时目标分组那一行可见性表可能还不存在,
// `RemapAfterSignalMove` 内部会自行扩展 —— 这里**不能**先 `SyncToDataSet`,
// 因为那次同步会按旧下标清理/修正引用,随后下标一变就会指向错误的信号。
if (sameGroup)
{
for (TabConfig& tab : m_tabs)
{
tab.RemapAfterSignalReorder(fromGroup, fromSignal, position);
}
}
else
{
for (TabConfig& tab : m_tabs)
{
tab.RemapAfterSignalMove(from, to, tab.IsVisible(from));
}
}
// 共享选中项:被移动的正是当前选中信号时,改为指向新位置。
if (m_dataSet.Selected().groupIndex == fromGroup)
{
if (sameGroup)
{
// 与 `RemapAfterSignalReorder` 同一套线性映射。
int sel = m_dataSet.Selected().signalIndex;
if (sel == fromSignal)
{
sel = position;
}
else if (fromSignal < position)
{
if (sel > fromSignal && sel <= position)
{
--sel;
}
}
else if (sel >= position && sel < fromSignal)
{
++sel;
}
SignalRef next;
next.groupIndex = fromGroup;
next.signalIndex = sel;
m_dataSet.SetSelected(next);
}
else if (m_dataSet.Selected().signalIndex == fromSignal)
{
m_dataSet.SetSelected(to);
}
}
int newSignalIndex = -1;
int newGroupIndex = -1;
if (createGroup)
{
// 建组 + 搬运做成一次原子操作(`AddGroup` 拒收空组,见 `MoveSignalToNewGroup`)。
if (!m_dataSet.MoveSignalToNewGroup(fromGroup, fromSignal, *newGroupName, newGroupIndex,
newSignalIndex))
{
// 前面已校验过,理论不可达;若真发生(数据层拒绝),必须把已改写的引用
// 按数据层的真实状态重新对齐,避免留下指向不存在分组的悬空引用。
SyncTabsToDataSet();
return false;
}
}
else
{
// 参数已在此前校验过(分组与信号下标都合法),本调用不会失败。
m_dataSet.MoveSignal(fromGroup, fromSignal, resolvedTo, position, newSignalIndex);
}
SyncTabsToDataSet();
return true;
}
void Application::ReorderSignal(int fromGroup, int fromSignal, int toGroup, int toIndex)
{
(void)MoveSignalCore(fromGroup, fromSignal, toGroup, toIndex, nullptr);
}
void Application::MoveSignalToGroup(int fromGroup, int fromSignal, int toGroup)
{
const SignalGroup* sourceGroup = m_dataSet.GroupAt(fromGroup);
if (sourceGroup == nullptr || fromSignal < 0 ||
static_cast<std::size_t>(fromSignal) >= sourceGroup->signals.size())
{
return;
}
const std::wstring signalName = sourceGroup->signals[static_cast<std::size_t>(fromSignal)].name;
if (toGroup < 0)
{
// 「移动到… → 新建分组」:组名固定为「新建分组」(重名自动加序号)。
const std::wstring name = UniqueGroupName(L"新建分组");
if (MoveSignalCore(fromGroup, fromSignal, -1, 0, &name))
{
SetStatusMessage(L"已将信号「" + signalName + L"」移动到新建分组:" + name, false);
}
return;
}
if (toGroup == fromGroup)
{
return; // 已经在该分组,无需移动
}
if (!MoveSignalCore(fromGroup, fromSignal, toGroup, -1, nullptr))
{
return;
}
const SignalGroup* destination = m_dataSet.GroupAt(toGroup);
SetStatusMessage(L"已将信号「" + signalName + L"」移动到分组:" +
(destination != nullptr ? destination->name : std::wstring()),
false);
}
void Application::UpdateWindowTitle()
{
// 标题栏 = 应用名称 + 分隔竖线 + 内存占用(单位在 KB/MB/GB 间自适应,保留两位小数)。
@@ -720,14 +920,19 @@ namespace jdi
derived.values.assign(derived_values.begin(), derived_values.end());
// 2b. 把源信号的「调整」变换**烘进派生采样值**:
// * 数值域(系数 → 绝对值 → 垂直镜像 → Y 偏移)逐点套用 `ApplyDrawTransform`;
// * 数值域(**公式映射** → 系数 → 绝对值 → 垂直镜像 → Y 偏移)逐点套用
// `ApplyDrawTransform`(它本来就是“公式最先结算”,与绘制路径同源);
// * 水平镜像 = 数据反序,等价于把采样数组整体反转。
// ⚠️ `ApplyDrawTransform` 是逐点函数,因此"先变换再反转"与"先反转再变换"等价。
// ⚠️⚠️ **公式映射必须算作“有变换”**(用户 2026-11-03):旧判定只列了系数/绝对值/
// 垂直镜像/Y 偏移,于是带公式的信号派生出来的新信号**丢掉了公式**(不烘也不继承)
// ⇒ 新信号曲线与预览曲线不一致。
if (hasSourceStyle)
{
const bool valueTransform =
(sourceTransform.gain != 1.0f || sourceTransform.absolute ||
sourceTransform.mirrorVertical || sourceTransform.offsetY != 0.0f);
sourceTransform.HasFormula() || sourceTransform.gain != 1.0f ||
sourceTransform.absolute || sourceTransform.mirrorVertical ||
sourceTransform.offsetY != 0.0f;
if (valueTransform)
{
for (double& value : derived.values)
@@ -806,6 +1011,8 @@ namespace jdi
// 1. 收集「变换后」的采样点(升序)。变换与绘制一致:
// 公式映射 → 系数 → 绝对值 → 垂直镜像 → Y 偏移(时间轴偏移不烘值)。
// ⚠️ 判定统一走 `HasValueTransform()`(含公式映射),否则带公式的信号
// 重采样时会**丢掉公式**(用户 2026-11-03 报告)。
const std::size_t count = std::min(axis.time.size(), source.values.size());
std::vector<double> sourceTimes;
std::vector<double> sourceValues;
@@ -942,6 +1149,172 @@ namespace jdi
return true;
}
// ---------------------------------------------------------------- 选区截取
bool Application::BuildClippedSignal(int groupIndex, int signalIndex, double begin, double end)
{
const SignalGroup* group = m_dataSet.GroupAt(groupIndex);
if (group == nullptr || signalIndex < 0 ||
signalIndex >= static_cast<int>(group->signals.size()))
{
return false;
}
if (!std::isfinite(begin) || !std::isfinite(end))
{
return false;
}
const double low = std::min(begin, end);
const double high = std::max(begin, end);
if (!(high > low))
{
m_plot.ShowPersistentHint(L"选区截取失败:双光标区间宽度为 0。");
return false;
}
const SignalData& source = group->signals[static_cast<std::size_t>(signalIndex)];
const TimeAxis& axis = group->AxisOf(source);
const std::wstring baseName = source.name;
// 源信号的整套「调整」快照(插入会移动信号存储,之后不能再解引用 `source`)。
SignalStyle sourceStyle;
bool hasSourceStyle = false;
{
const SignalStyle* style = m_dataSet.Style(SignalRef{groupIndex, signalIndex});
if (style != nullptr)
{
sourceStyle = *style;
hasSourceStyle = true;
}
}
// 1. 逐点收集**区间内**的原始采样(不烘焙任何调整)。
// ⚠️⚠️ 比较必须用**显示时刻**(`time + offsetTime`):双光标给出的是屏幕上看到的时刻,
// 而采样画在 `time + offsetTime` 处(`offsetTime` 不进采样值,只平移绘制位置)。
// 若直接拿 `time` 与选区比较,带时间轴偏移的信号会截错区间(用户 2026-11-03)。
const double timeShift = hasSourceStyle ? sourceStyle.offsetTime : 0.0;
const std::size_t count = std::min(axis.time.size(), source.values.size());
std::vector<double> keptTimes;
std::vector<double> keptValues;
keptTimes.reserve(count);
keptValues.reserve(count);
for (std::size_t index = 0; index < count; ++index)
{
const double time = axis.time[index];
if (!std::isfinite(time))
{
continue;
}
const double shown = time + timeShift;
if (shown < low || shown > high)
{
continue;
}
keptTimes.push_back(time);
keptValues.push_back(source.values[index]);
}
if (keptValues.size() < 2)
{
m_plot.ShowPersistentHint(L"选区截取失败:区间内的有效采样点不足两个。");
return false;
}
// 2. 名称尾缀:`(AAAA - BBBB)`,秒、保留三位小数(用户 2026-11-03 明确要求)。
// 数值取**显示时刻**(与双光标读数一致),因此加上 `timeShift`。
const std::wstring suffix =
L" (" + FormatDecimalFixed(keptTimes.front() + timeShift, 3) + L"s - " +
FormatDecimalFixed(keptTimes.back() + timeShift, 3) + L"s)";
// 3. 新建时间轴(截取后的采样刻度无法复用源信号的时间轴)。
TimeAxis newAxis;
newAxis.name = axis.name;
newAxis.time = std::move(keptTimes);
newAxis.monotonic = true;
newAxis.finiteCount = newAxis.time.size();
newAxis.minTime = newAxis.time.front();
newAxis.maxTime = newAxis.time.back();
std::size_t axisIndex = 0;
if (!m_dataSet.AppendGroupAxis(groupIndex, std::move(newAxis), axisIndex))
{
return false;
}
// 4. 组装新信号并插入到源信号下方。
SignalData derived;
derived.name = baseName + suffix;
derived.axisIndex = axisIndex;
derived.values = std::move(keptValues);
int newIndex = -1;
if (!InsertSignalBelowSource(groupIndex, signalIndex, std::move(derived), true, newIndex))
{
return false;
}
// 5. 继承源信号的**整套调整**(不烘值 ⇒ 新曲线与源信号在区间内逐像素重合)。
if (hasSourceStyle)
{
if (SignalStyle* derivedStyle = m_dataSet.Style(SignalRef{groupIndex, newIndex}))
{
derivedStyle->color = sourceStyle.color;
derivedStyle->customColor = sourceStyle.customColor;
derivedStyle->opacity = sourceStyle.opacity;
derivedStyle->lineWidth = sourceStyle.lineWidth;
derivedStyle->mode = sourceStyle.mode;
derivedStyle->formula = sourceStyle.formula;
derivedStyle->gain = sourceStyle.gain;
derivedStyle->absolute = sourceStyle.absolute;
derivedStyle->mirrorVertical = sourceStyle.mirrorVertical;
derivedStyle->mirrorHorizontal = sourceStyle.mirrorHorizontal;
derivedStyle->offsetY = sourceStyle.offsetY;
derivedStyle->offsetTime = sourceStyle.offsetTime;
}
}
SyncTabsToDataSet();
return true;
}
bool Application::ClipSignalInPlace(int groupIndex, int signalIndex, double begin, double end)
{
if (!std::isfinite(begin) || !std::isfinite(end))
{
return false;
}
const double low = std::min(begin, end);
const double high = std::max(begin, end);
if (!(high > low))
{
m_plot.ShowPersistentHint(L"选区截取失败:双光标区间宽度为 0。");
return false;
}
// 破坏性截取会移动/释放采样 ⇒ 先暂停后台派生计算,避免它读到已释放的数据。
m_plot.PauseDerivedAsync();
// ⚠️ 双光标给的是**显示时刻**,而采样时刻是 `time`;这里换算回采样时刻区间
// (`offsetTime` 不烘进采样值,只平移绘制位置)。
double timeShift = 0.0;
if (const SignalStyle* style = m_dataSet.Style(SignalRef{groupIndex, signalIndex}))
{
timeShift = style->offsetTime;
}
// ⚠️⚠️ **必须先让该信号独占时间轴**再裁剪:同组信号常常共享同一条时间轴,
// 直接裁剪会把其它信号的时间基准一起改短 ⇒ 它们的采样与时间错位
//(用户 2026-11-03 报告:"多个信号同时截取时截取出来的信号并不对")。
std::size_t ownAxis = 0;
if (!m_dataSet.ForkAxisForSignal(groupIndex, signalIndex, ownAxis))
{
m_plot.ShowPersistentHint(L"选区截取失败:无法为该信号创建独立时间轴。");
return false;
}
if (!m_dataSet.ClipSignalRange(groupIndex, signalIndex, low - timeShift, high - timeShift))
{
m_plot.ShowPersistentHint(L"选区截取失败:区间内的有效采样点不足两个。");
return false;
}
SyncTabsToDataSet();
return true;
}
bool Application::InsertSignalBelowSource(int groupIndex, int sourceIndex, SignalData&& signal,
bool makeVisible, int& newIndex)
{
@@ -978,7 +1351,7 @@ namespace jdi
}
const SignalData& source = group->signals[static_cast<std::size_t>(sourceRef.signalIndex)];
// 源信号的「调整」快照(插入会移动信号存储,之后不能再解引用 `source`)。
// 源信号的样式快照(插入会移动信号存储,之后不能再解引用 `source`)。
SignalStyle transform;
bool hasStyle = false;
if (const SignalStyle* style = m_dataSet.Style(sourceRef))
@@ -987,29 +1360,17 @@ namespace jdi
hasStyle = true;
}
// 克隆 = 把「调整」烘进采样值(**时间轴偏移除外**,它留在样式上)。
// 与「构建为新信号」完全同一套烘值规则,因此克隆出来的曲线与原信号**逐像素重合**,
// 但它的「调整」面板回到默认值,可以继续在此基础上继续叠加新调整。
// ⚠️ 用户 2026-11-03 明确要求:「克隆该信号」**不做任何烘焙**。
// 新信号直接使用源信号的**原始采样数据**,配方上只沿用:
// * 外观(颜色 / 自定义色 / 透明度 / 线宽 / 绘制方式);
// * **时间轴偏移**(`offsetTime` 只能留在样式上 —— 共用同一条时间轴,
// 烘焙进采样值会在越界段写 NaN,见 `BuildDerivedSignal` 2c 的说明)。
// 公式映射 / 系数 / 绝对值 / 镜像 / Y 偏移一律**保持默认**(不烘也不应用)。
// 需要“连调整一起复制”请用「克隆该信号(带调整)」(`CloneSignalAdjusted`)。
SignalData clone;
clone.name = L"*" + source.name;
clone.axisIndex = source.axisIndex;
clone.values = source.values;
if (hasStyle)
{
const bool valueTransform = (transform.gain != 1.0f || transform.absolute ||
transform.mirrorVertical || transform.offsetY != 0.0f);
if (valueTransform)
{
for (double& value : clone.values)
{
value = ApplyDrawTransform(transform, value);
}
}
if (transform.mirrorHorizontal)
{
std::reverse(clone.values.begin(), clone.values.end());
}
}
int newIndex = -1;
if (!InsertSignalBelowSource(sourceRef.groupIndex, sourceRef.signalIndex, std::move(clone),
@@ -1018,8 +1379,7 @@ namespace jdi
return false;
}
// 外观:克隆要**看起来与原信号一样**(颜色 / 透明度 / 线宽 / 绘制样式),
// 只把被烘掉的「调整」复位为默认值,并保留时间轴偏移。
// 外观:克隆要**看起来与原信号一样**,但所有数值调整保持默认值。
if (SignalStyle* cloned = m_dataSet.Style(SignalRef{sourceRef.groupIndex, newIndex}))
{
if (hasStyle)
@@ -1029,6 +1389,7 @@ namespace jdi
cloned->opacity = transform.opacity;
cloned->lineWidth = transform.lineWidth;
cloned->mode = transform.mode;
// ⚠️ 只保留时间轴偏移,其余调整保持默认(用户 2026-11-03 明确要求)。
cloned->offsetTime = transform.offsetTime;
}
// ⚠️ 单点信号默认会改成「散点 + 最大线宽」(`InsertDerivedSignal` 里的规则),
@@ -1044,6 +1405,70 @@ namespace jdi
return true;
}
bool Application::CloneSignalAdjusted(const SignalRef& sourceRef)
{
const SignalGroup* group = m_dataSet.GroupAt(sourceRef.groupIndex);
if (group == nullptr || sourceRef.signalIndex < 0 ||
sourceRef.signalIndex >= static_cast<int>(group->signals.size()))
{
return false;
}
const SignalData& source = group->signals[static_cast<std::size_t>(sourceRef.signalIndex)];
SignalStyle transform;
bool hasStyle = false;
if (const SignalStyle* style = m_dataSet.Style(sourceRef))
{
transform = *style;
hasStyle = true;
}
// 「克隆该信号(带调整)」:同样是**原始采样数据**(不做烘焙),
// 区别只在样式——完整沿用源信号的整套调整参数(公式映射 / 系数 / Y 偏移 /
// 时间轴偏移 / 水平与垂直镜像 / 绝对值),因此两条曲线**逐像素重合**,
// 且新信号可以独立继续调参而不影响源信号(用户 2026-11-03 明确要求)。
SignalData clone;
clone.name = L"*" + source.name;
clone.axisIndex = source.axisIndex;
clone.values = source.values;
int newIndex = -1;
if (!InsertSignalBelowSource(sourceRef.groupIndex, sourceRef.signalIndex, std::move(clone),
true, newIndex))
{
return false;
}
if (SignalStyle* cloned = m_dataSet.Style(SignalRef{sourceRef.groupIndex, newIndex}))
{
if (hasStyle)
{
// 外观。
cloned->color = transform.color;
cloned->customColor = transform.customColor;
cloned->opacity = transform.opacity;
cloned->lineWidth = transform.lineWidth;
cloned->mode = transform.mode;
// 整套「调整」参数(与源信号完全一致)。
cloned->formula = transform.formula;
cloned->gain = transform.gain;
cloned->absolute = transform.absolute;
cloned->mirrorVertical = transform.mirrorVertical;
cloned->mirrorHorizontal = transform.mirrorHorizontal;
cloned->offsetY = transform.offsetY;
cloned->offsetTime = transform.offsetTime;
}
}
SyncTabsToDataSet();
SetStatusMessage(L"已克隆信号(带调整):" + m_dataSet.GroupAt(sourceRef.groupIndex)
->signals[static_cast<std::size_t>(newIndex)]
.name,
false);
InvalidateRect(m_hwnd, nullptr, TRUE);
return true;
}
bool Application::ApplyPendingPanelScroll()
{
SignalRef ref;
@@ -1106,6 +1531,42 @@ namespace jdi
}
}
// 「选区截取」:两个按钮(截取为新信号 / 原始信号截取)共用同一份区间。
{
int laneIndex = -1;
std::vector<PlotView::ClipBuild> items;
if (m_plot.TakePendingClipBuild(laneIndex, items) && !items.empty())
{
// 同一文件内必须按下标降序处理(插入会让后续下标 +1)。
std::sort(items.begin(), items.end(),
[](const PlotView::ClipBuild& a, const PlotView::ClipBuild& b)
{
if (a.groupIndex != b.groupIndex)
{
return a.groupIndex < b.groupIndex;
}
return a.signalIndex > b.signalIndex;
});
int built = 0;
for (const PlotView::ClipBuild& item : items)
{
if (item.destructive
? ClipSignalInPlace(item.groupIndex, item.signalIndex, item.begin, item.end)
: BuildClippedSignal(item.groupIndex, item.signalIndex, item.begin, item.end))
{
++built;
}
}
if (built > 0)
{
SetStatusMessage((items.front().destructive ? L"已截取 " : L"已截取为新信号 ") +
std::to_wstring(built) + L" 条。",
false);
}
return built > 0;
}
}
// 两类「构建为新信号」请求(均值滤波 / 弱峰调整)机制完全一致,
// 统一整理成同一份构建清单后按下标降序处理。
std::vector<DerivedSignalRequest> requests;
@@ -1338,6 +1799,14 @@ namespace jdi
resample.id = PLOT_CMD_LANE_RESAMPLE;
resample.checked = m_plot.LaneResample(laneIndex);
experimental.submenu.push_back(resample);
// 「选区截取」位于「重采样」下方、「自由标记」上方(用户 2026-11-03 要求)。
MenuItem clip;
clip.label = L"选区截取";
clip.id = PLOT_CMD_LANE_CLIP;
clip.checked = m_plot.LaneClip(laneIndex);
experimental.submenu.push_back(clip);
experimental.submenu.push_back(freeMarker);
}
items.push_back(experimental);
@@ -1642,6 +2111,10 @@ namespace jdi
// 「实验性功能 → 重采样」:切换重采样参数面板。
m_plot.ToggleLaneResample(laneIndex);
break;
case PLOT_CMD_LANE_CLIP:
// 「实验性功能 → 选区截取」:切换选区截取面板。
m_plot.ToggleLaneClip(laneIndex);
break;
case PLOT_CMD_LANE_SPLIT:
// 「拆分当前网格」:信号按个拆成独立网格,新网格顺次插入其下方。
m_plot.SplitLane(laneIndex);
+73 -2
View File
@@ -127,7 +127,8 @@ namespace jdi
PLOT_CMD_LANE_MEAN_FILTER = 10, /**< 切换本网格的「实验性功能 → 均值滤波」开关。 */
PLOT_CMD_LANE_WEAK_PEAK = 11, /**< 切换本网格的「实验性功能 → 弱峰调整」开关。 */
PLOT_CMD_LANE_FREE_MARKER = 12, /**< 切换本网格的「实验性功能 → 自由标记」开关。 */
PLOT_CMD_LANE_RESAMPLE = 13 /**< 切换本网格的「实验性功能 → 重采样」开关。 */
PLOT_CMD_LANE_RESAMPLE = 13, /**< 切换本网格的「实验性功能 → 重采样」开关。 */
PLOT_CMD_LANE_CLIP = 14 /**< 切换本网格的「实验性功能 → 选区截取」开关。 */
};
/**
@@ -140,7 +141,8 @@ namespace jdi
MEAN = 2, /**< 均值滤波。 */
PEAK = 3, /**< 弱峰调整。 */
MARK = 4, /**< 自由标记。 */
RESAMPLE = 5 /**< 重采样(把非固定频率采样规整为固定频率)。 */
RESAMPLE = 5, /**< 重采样(把非固定频率采样规整为固定频率)。 */
CLIP = 6 /**< 选区截取(按双光标区间截取信号,必须配合双光标使用)。 */
};
/**
@@ -1087,6 +1089,51 @@ namespace jdi
*/
bool SignalDataInterval(const SignalRef& ref, double& maxInterval, double& minInterval) const;
// ------------------------------------------------------------------ 选区截取
/**
* @brief 「选区截取」的单个请求(一个被勾选的信号 + 截取区间 + 是否破坏原信号)。
*/
struct ClipBuild
{
int groupIndex = -1; /**< 信号所属分组下标。 */
int signalIndex = -1; /**< 组内信号下标。 */
double begin = 0.0; /**< 截取区间左端(显示时刻,秒)。 */
double end = 0.0; /**< 截取区间右端(显示时刻,秒)。 */
/** @brief `true` = 破坏性截取原始信号;`false` = 插到源信号下方的派生信号。 */
bool destructive = false;
};
/**
* @brief 取出「选区截取」请求(由面板底部两个按钮触发)。
* @param [OUT] laneIndex 触发该请求的网格下标。
* @param [OUT] items 需要截取的信号及其区间。
* @return 存在待处理请求返回 true(取出后清空)。
*/
bool TakePendingClipBuild(int& laneIndex, std::vector<ClipBuild>& items);
/**
* @brief 开启 / 关闭某网格的「选区截取」功能。
* @param [IN] laneIndex 网格下标。
* @param [IN] enabled 是否开启。
* @return 状态发生变化返回 true。
*/
bool SetLaneClip(int laneIndex, bool enabled);
/** @brief 查询某网格的「选区截取」开关。 */
bool LaneClip(int laneIndex) const;
/** @brief 切换某网格的「选区截取」开关(右键菜单用)。 */
bool ToggleLaneClip(int laneIndex);
/** @brief 切换某信号是否为选区截取的目标(面板条目栏右侧复选框)。 */
bool ToggleLaneClipSignal(int laneIndex, const SignalRef& ref);
/** @brief 查询某信号是否为选区截取的目标。 */
bool LaneClipSignalEnabled(int laneIndex, const SignalRef& ref) const;
/** @brief 设置「选区截取」面板内容的滚动偏移(自动夹到合法范围)。 */
bool SetLaneClipScroll(int laneIndex, float scroll);
/**
* @brief 设置某网格是否处于「自由标记点选取」(浮动面板底部的切换按钮)。
*
@@ -2119,6 +2166,17 @@ namespace jdi
gd::RectF showDelta;
/** @brief 「显示枚举文本」开关按钮(同一行的右半)。 */
gd::RectF showEnum;
/**
* @brief 「选区截取」底部的两个固定按钮(**不参与滚动**)。
*
* `clipBuildButton` = 「截取为新信号」(在源信号下方插入派生信号);
* `clipDestructiveButton` = 「原始信号截取」(破坏性截取,丢弃区间外的采样)。
* 未启用双光标模式时两者都是空矩形(不可点)。
*/
gd::RectF clipBuildButton;
gd::RectF clipDestructiveButton;
/** @brief 「选区截取」需要双光标时的提示文案矩形(未启用双光标时使用)。 */
gd::RectF clipHint;
/**
* @brief 「自由标记」:每个信号名右侧的「清除该信号全部固定点」✕。
*
@@ -3015,6 +3073,9 @@ namespace jdi
std::vector<WeakPeakBuild> m_pendingWeakPeakItems; /**< 待构建的弱峰调整信号及其参数。 */
int m_pendingResampleLane = -1; /**< 待构建的重采样信号所属网格下标(-1 表示无请求)。 */
std::vector<ResampleBuild> m_pendingResampleItems; /**< 待构建的重采样信号及其间隔。 */
int m_pendingClipLane = -1; /**< 待截取的信号所属网格下标(-1 表示无请求)。 */
std::vector<ClipBuild> m_pendingClipItems; /**< 待截取的信号及其区间。 */
bool m_mouseOverPanel = false; /**< 上一帧鼠标是否落在某个实验性功能面板上。 */
int m_peakDragLane = -1; /**< 正在拖动弱峰滑块的面板所属网格。 */
SignalRef m_peakDragRef; /**< 正在拖动弱峰滑块的**信号**(每个信号一套参数)。 */
@@ -3971,6 +4032,16 @@ namespace jdi
*/
void PaintLaneReorder(gd::Graphics& g);
/**
* @brief 网格拖拽重排的**叠加提示**:目标槽位高亮 + 被拖网格轮廓。
*
* 与 `PaintLaneReorder` 分离是因为「显示布局边界」调试模式(`m_forceFullRepaint`)
* 下无法走贴图路径(会残留上一帧的调试框),必须改为整幅重绘;此时网格本体由
* 整幅重绘按位移逐格绘制,而这两项提示仍需同一套代码补画。
* @param [IN,OUT] g 绘图上下文。
*/
void PaintLaneReorderOverlay(gd::Graphics& g);
/**
* @brief 结束网格拖拽重排:若目标位置与原位置不同则真正重排并同步编号。
*/
+50 -26
View File
@@ -607,6 +607,21 @@ namespace jdi
inline const double kResampleIntervalMin = 1.0e-9;
inline const double kResampleIntervalMax = 1.0e6;
// ---- 「选区截取」面板 ----
/** 「选区截取」面板标题。 */
inline const wchar_t* const kClipTitle = L"选区截取";
/** 底部按钮:「截取为新信号」(在源信号下方插入派生信号)。 */
inline const wchar_t* const kClipBuildLabel = L"截取为新信号";
/** 底部按钮:「原始信号截取」(破坏性截取,丢弃区间外的采样)。 */
inline const wchar_t* const kClipDestructiveLabel = L"原始信号截取";
/**
* @brief 未启用双光标模式时面板给出的提示。
*
* 该功能依赖「双光标」定义的截取区间,因此单光标/无光标时无法工作。
* ⚠️ 文案由用户 2026-11-03 指定,不要改写。
*/
inline const wchar_t* const kClipNeedDoubleCursorHint = L"启用双光标模式以进行选区截取";
// ---- 「公式映射」的分组包络缓存(见 `PlotView::AcquireFormulaEnvelope`)----
/**
* @brief 每个信号的包络分组数上限(分组宽度按采样数反算)。
@@ -1066,7 +1081,8 @@ namespace jdi
*/
inline const EdgePanelKind kEdgePanelKinds[] = {EdgePanelKind::COUNT, EdgePanelKind::CENTER,
EdgePanelKind::MEAN, EdgePanelKind::PEAK,
EdgePanelKind::MARK, EdgePanelKind::RESAMPLE};
EdgePanelKind::MARK, EdgePanelKind::RESAMPLE,
EdgePanelKind::CLIP};
/** 实验性功能浮动面板的种类数(遍历与命中测试统一用它,避免新增功能时漏改)。 */
inline const int kEdgePanelKindCount = static_cast<int>(sizeof(kEdgePanelKinds) / sizeof(kEdgePanelKinds[0]));
@@ -1306,34 +1322,17 @@ namespace jdi
return FormatValue(seconds) + L"s";
}
/**
* @brief 把时间间隔四舍五入到 **3 位有效数字**(供「重采样」面板显示)。
*
* 计算出来的数据间隔常常带一长串浮点尾数(如 `5.0000158us`)。整串显示既没有意义,
* 又会把面板撑得很宽(还要在后面追加「(频率)」尾缀,用户 2026-11-03 要求)。
* 3 位有效数字足以区分相邻的采样间隔,且让显示稳定。
*
* @param [IN] seconds 时间间隔(秒)。
* @return 舍入后的间隔;非法/非正输入原样返回。
*/
inline double RoundIntervalForDisplay(double seconds)
{
if (!std::isfinite(seconds) || seconds <= 0.0)
{
return seconds;
}
const double magnitude = std::floor(std::log10(seconds));
const double scale = std::pow(10.0, 2.0 - magnitude);
const double rounded = std::round(seconds * scale) / scale;
return (rounded > 0.0) ? rounded : seconds;
}
/**
* @brief 时间间隔的显示文本(**不带** "Δt:" 前缀)。
*
* 「重采样」面板的「最大 / 最小数据间隔」两行用它:单位随间隔长短自适应
*(`FormatDurationCompound`:1h 1min 2s / 5s 60ms / 120us …),
* 且先舍入到 3 位有效数字(见 `RoundIntervalForDisplay`),**不出现科学计数法**。
*(`FormatDurationCompound`:1h 1min 2s / 5s 60ms / 3ms 696.631us …),
* **绝不出现科学计数法**。
*
* ⚠️⚠️ **不要在这里做舍入**(用户 2026-11-03 明确要求):
* 旧实现先把值舍入到 3 位**有效数字**再格式化,把真实尾数抹掉了 ——
* `0.003696631s` 被显示成 `3ms 700us`(而正确是 `3ms 696.631us`)。
* 现在一律用**真实值**(`FormatDurationCompound` 内部把次级单位保留 3 位小数)。
*
* @param [IN] seconds 时间间隔(秒)。
* @return 文本。
@@ -1344,7 +1343,7 @@ namespace jdi
{
return L"—";
}
return FormatDurationCompound(RoundIntervalForDisplay(seconds));
return FormatDurationCompound(seconds);
}
/**
@@ -1359,6 +1358,31 @@ namespace jdi
return FormatFrequency(hertz);
}
/**
* @brief 「重采样」面板统计行的完整文本:`<间隔>(<频率>)`。
*
* ⚠️ **唯一实现**:面板**宽度计算**与**绘制**必须都调它,
* 否则宽度按"典型文本"估算、实际文本更长时会被 `UiEllipsize` 截成 `…`
* (用户 2026-11-03 报告"统计信息显示不完整")。
*
* 例:`3ms 696.631us(270.517 Hz)`、`16ms 182.614us(61.795 Hz)`。
*
* @param [IN] seconds 该统计行对应的间隔(秒)。
* @return 完整文本;非法值返回 "—"。
*/
inline std::wstring FormatIntervalWithFrequency(double seconds)
{
if (!std::isfinite(seconds) || seconds <= 0.0)
{
return L"—";
}
std::wstring text = FormatDurationCompound(seconds);
text += L"(";
text += FormatFrequencyText(1.0 / seconds);
text += L")";
return text;
}
/**
* @brief 按比例缩放颜色的 alpha 通道(用于提示浮层淡出)。
* @param [IN] argb 原始 0xAARRGGBB 颜色。
@@ -179,6 +179,15 @@ namespace jdi
mix(static_cast<std::uint64_t>(item.amountPercent));
mix(static_cast<std::uint64_t>(item.iterations));
}
// 「选区截取」:开关 + 每个被勾选信号(只影响面板像素,不影响网格曲线内容,
// 但与其它面板一样放在这里以统一定义;面板自身另有 `PanelSlotKey`)。
mix(Tab().Lanes()[laneIndex].clip ? 1ull : 0ull);
mix(static_cast<std::uint64_t>(Tab().Lanes()[laneIndex].clipItems.size()));
for (const Lane::ClipItem& item : Tab().Lanes()[laneIndex].clipItems)
{
mix(static_cast<std::uint64_t>(item.ref.groupIndex));
mix(static_cast<std::uint64_t>(item.ref.signalIndex));
}
}
mix(signals.size());
for (const SignalRef& ref : signals)
@@ -1990,6 +1990,115 @@ namespace jdi
return true;
}
// ============================ 「选区截取」 ============================
bool PlotView::SetLaneClip(int laneIndex, bool enabled)
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
if (lane.clip == enabled)
{
return false;
}
lane.clip = enabled;
if (enabled)
{
// 默认只勾选网格内**第一个可见信号**(与其它实验性功能一致)。
if (lane.clipItems.empty())
{
for (const SignalRef& ref : lane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data != nullptr && resolved.visible)
{
lane.EnsureClipItem(ref);
break;
}
}
}
lane.clipPanel.open = true;
}
else
{
lane.clipPanel.open = false;
}
EnsureLaneLayersPrefetched();
return true;
}
bool PlotView::LaneClip(int laneIndex) const
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
return m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].clip;
}
bool PlotView::ToggleLaneClip(int laneIndex)
{
const bool enabled = !LaneClip(laneIndex);
SetLaneClip(laneIndex, enabled);
return enabled;
}
bool PlotView::ToggleLaneClipSignal(int laneIndex, const SignalRef& ref)
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
if (lane.ClipEnabled(ref))
{
lane.RemoveClipItem(ref);
return false;
}
lane.EnsureClipItem(ref);
return true;
}
bool PlotView::LaneClipSignalEnabled(int laneIndex, const SignalRef& ref) const
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
return m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].ClipEnabled(ref);
}
bool PlotView::SetLaneClipScroll(int laneIndex, float scroll)
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
const float clamped = std::max(0.0f, scroll);
if (std::fabs(lane.clipScroll - clamped) < 0.5f)
{
return false;
}
lane.clipScroll = clamped;
return true;
}
bool PlotView::TakePendingClipBuild(int& laneIndex, std::vector<ClipBuild>& items)
{
if (m_pendingClipLane < 0)
{
return false;
}
laneIndex = m_pendingClipLane;
items = m_pendingClipItems;
m_pendingClipLane = -1;
m_pendingClipItems.clear();
return true;
}
bool PlotView::SetLaneEdgeCenterScroll(int laneIndex, float scroll) {
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
@@ -2098,6 +2207,9 @@ namespace jdi
case EdgePanelKind::RESAMPLE:
state = &lane.resamplePanel;
break;
case EdgePanelKind::CLIP:
state = &lane.clipPanel;
break;
default:
return false;
}
@@ -2130,6 +2242,8 @@ namespace jdi
return lane.freeMarkerPanel.collapsed;
case EdgePanelKind::RESAMPLE:
return lane.resamplePanel.collapsed;
case EdgePanelKind::CLIP:
return lane.clipPanel.collapsed;
default:
return false;
}
+145 -5
View File
@@ -1066,6 +1066,18 @@ namespace jdi
{
return LoadCursorW(nullptr, IDC_HAND);
}
// 「选区截取」底部两个按钮:可点击控件(双光标关闭时为空矩形 ⇒ 不响应)。
if (layout.clipBuildButton.Width > 0.0f &&
UiPointInRect(layout.clipBuildButton, static_cast<float>(x), static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
if (layout.clipDestructiveButton.Width > 0.0f &&
UiPointInRect(layout.clipDestructiveButton, static_cast<float>(x),
static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
if (layout.opacityTrack.Width > 0.0f &&
WeakPeakSliderHit(layout.opacityTrack, m_scale, static_cast<float>(x),
static_cast<float>(y)))
@@ -1109,7 +1121,8 @@ namespace jdi
const bool listCursor = (cursorKind == EdgePanelKind::MEAN ||
cursorKind == EdgePanelKind::CENTER ||
cursorKind == EdgePanelKind::PEAK ||
cursorKind == EdgePanelKind::RESAMPLE);
cursorKind == EdgePanelKind::RESAMPLE ||
cursorKind == EdgePanelKind::CLIP);
const Lane& cursorLane =
Tab().Lanes()[static_cast<std::size_t>(panelLane)];
if (listCursor)
@@ -1136,13 +1149,28 @@ namespace jdi
checked = cursorLane.FindResampleItem(
layout.rowRefs[rowIndex]) != nullptr;
}
else if (cursorKind == EdgePanelKind::CLIP)
{
checked = cursorLane.ClipEnabled(layout.rowRefs[rowIndex]);
}
else
{
checked = cursorLane.FindMeanFilterItem(
layout.rowRefs[rowIndex]) != nullptr;
}
// ⚠️⚠️ **「选区截取」的条目栏不可展开**(只有右侧复选框有效):
// 因此条目栏本体**绝不能**给手型 —— 否则会误导用户去点它
//(用户 2026-11-03 明确要求)。
if (cursorKind == EdgePanelKind::CLIP)
{
return nullptr;
}
return checked ? LoadCursorW(nullptr, IDC_HAND) : nullptr;
}
if (cursorKind == EdgePanelKind::CLIP)
{
continue; // 截取面板的条目只有复选框,没有参数行
}
if (cursorKind == EdgePanelKind::RESAMPLE)
{
// 两行输入框是可点击控件(点击进入就地编辑)。
@@ -1728,6 +1756,16 @@ namespace jdi
lane.freeMarkerPanel.x = left;
lane.freeMarkerPanel.y = top;
break;
case EdgePanelKind::RESAMPLE:
// ⚠️ 同类缺陷:漏掉这一支会让「重采样」面板**拖不动**(用户 2026-11-03 报告)。
// 位置必须写进它自己的 `resamplePanel`,布局也只从那里读。
lane.resamplePanel.x = left;
lane.resamplePanel.y = top;
break;
case EdgePanelKind::CLIP:
lane.clipPanel.x = left;
lane.clipPanel.y = top;
break;
case EdgePanelKind::COUNT:
default:
lane.edgePanel.x = left;
@@ -1826,6 +1864,9 @@ namespace jdi
case EdgePanelKind::RESAMPLE:
scrolled = SetLaneResampleScroll(m_markerScrollDragLane, target);
break;
case EdgePanelKind::CLIP:
scrolled = SetLaneClipScroll(m_markerScrollDragLane, target);
break;
default:
scrolled = SetLaneMeanFilterScroll(m_markerScrollDragLane, target);
break;
@@ -2008,7 +2049,8 @@ namespace jdi
static_cast<EdgePanelKind>(hoverPanelKind);
if (overEdgePanel && m_tab != nullptr && panelLane >= 0 &&
(hoverKind == EdgePanelKind::MEAN || hoverKind == EdgePanelKind::PEAK ||
hoverKind == EdgePanelKind::CENTER || hoverKind == EdgePanelKind::RESAMPLE))
hoverKind == EdgePanelKind::CENTER || hoverKind == EdgePanelKind::RESAMPLE ||
hoverKind == EdgePanelKind::CLIP))
{
const EdgePanelLayout layout = EdgePanelLayoutFor(panelLane, hoverKind);
if (UiPointInRect(layout.content, static_cast<float>(x), static_cast<float>(y)))
@@ -2405,16 +2447,17 @@ namespace jdi
// (前两者是「名称 + 复选框 + 两个参数输入框」,弱峰调整是「名称 + 复选框 + 两行滑块」)。
if ((kind == EdgePanelKind::MEAN || kind == EdgePanelKind::CENTER ||
kind == EdgePanelKind::PEAK || kind == EdgePanelKind::COUNT ||
kind == EdgePanelKind::RESAMPLE) &&
kind == EdgePanelKind::RESAMPLE || kind == EdgePanelKind::CLIP) &&
m_tab != nullptr)
{
const bool meanMode = (kind == EdgePanelKind::MEAN);
const bool centerMode = (kind == EdgePanelKind::CENTER);
const bool boostMode = (kind == EdgePanelKind::PEAK);
const bool resampleMode = (kind == EdgePanelKind::RESAMPLE);
const bool clipMode = (kind == EdgePanelKind::CLIP);
// 「均值滤波」「中心轴边沿计数」「弱峰调整」「重采样」自 2026-11-02 起共用同一套
//**可滚动列表**交互(信号色叠底条目栏 + 右侧复选框 + 展开后的参数行)。
const bool listMode = (meanMode || centerMode || boostMode || resampleMode);
const bool listMode = (meanMode || centerMode || boostMode || resampleMode || clipMode);
// 「边沿计数」= **表格**(表头 + 每信号一行),只有滚动条可拖。
if (kind == EdgePanelKind::COUNT)
{
@@ -2566,9 +2609,93 @@ namespace jdi
m_dragMode = PlotDragMode::NONE;
return true;
}
// ⚠️⚠️ 这里**不能**无条件 `return true`:标题栏的「拖动面板」判定在
// 面板分支的**最末尾**(`if (UiPointInRect(layout.title, ...))`),
// 若无条件吞掉本次按下,标题栏永远进不了 `EDGE_PANEL_MOVE`
// ⇒ 「重采样」面板**拖不动**(用户 2026-11-03 报告)。
// 因此只消费**内容区之内**的点击(含半截滚出的行),标题栏留给下面。
if (insideContent)
{
m_dragMode = PlotDragMode::NONE;
return true; // 面板内的其它位置:消费点击,不穿透
}
}
// 「选区截取」:条目只有**右侧复选框**(不可展开),底部两个固定按钮。
if (clipMode)
{
const bool doubleCursor = (Tab().CursorMode() == PlotCursorMode::DOUBLE);
Lane& clipLane = m_tab->Lanes()[static_cast<std::size_t>(index)];
// 条目复选框:切换该信号是否为截取目标。
for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex)
{
if (!insideContent || rowIndex >= layout.rowChecks.size() ||
layout.rowChecks[rowIndex].Width <= 0.0f ||
!UiPointInRect(layout.rowChecks[rowIndex], fx, fy))
{
continue;
}
ToggleLaneClipSignal(index, layout.rowRefs[rowIndex]);
m_meanRowHoverLane = -1;
m_meanRowHoverRef = SignalRef();
m_dragMode = PlotDragMode::NONE;
return true;
}
// 底部按钮(仅在双光标模式下有几何)。
const bool onBuild = layout.clipBuildButton.Width > 0.0f &&
UiPointInRect(layout.clipBuildButton, fx, fy);
const bool onDestructive = layout.clipDestructiveButton.Width > 0.0f &&
UiPointInRect(layout.clipDestructiveButton, fx, fy);
if (onBuild || onDestructive)
{
if (!doubleCursor)
{
SetHint(kClipNeedDoubleCursorHint);
m_dragMode = PlotDragMode::NONE;
return true;
}
// 截取区间 = 双光标(左 = 较小时间,右 = 较大时间)。
const double begin = std::min(Tab().CursorTime(), Tab().CursorTimeAlt());
const double end = std::max(Tab().CursorTime(), Tab().CursorTimeAlt());
m_pendingClipItems.clear();
for (const SignalRef& ref : clipLane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || !resolved.visible ||
!clipLane.ClipEnabled(ref))
{
continue;
}
ClipBuild build;
build.groupIndex = ref.groupIndex;
build.signalIndex = ref.signalIndex;
build.begin = begin;
build.end = end;
build.destructive = onDestructive;
m_pendingClipItems.push_back(build);
}
if (m_pendingClipItems.empty())
{
SetHint(L"选区截取:当前网格没有勾选任何信号。");
}
else
{
SetHint(onDestructive
? L"正在按选区截取原始信号(" +
std::to_wstring(m_pendingClipItems.size()) +
L" 条,区间外数据将被丢弃)…"
: L"正在按选区截取为新信号(" +
std::to_wstring(m_pendingClipItems.size()) + L" 条)…");
m_pendingClipLane = index;
}
m_dragMode = PlotDragMode::NONE;
return true;
}
if (insideContent)
{
m_dragMode = PlotDragMode::NONE;
return true; // 内容区其它位置:消费点击,不穿透
}
}
// 「弱峰调整」:两行滑块 = 调整幅度(-400% … +400%)/ 迭代次数(1 … 自定上限)。
// 语义与该面板旧式排版完全一致,只是外观改成与均值滤波同款的可滚动列表。
if (boostMode)
@@ -3306,6 +3433,14 @@ namespace jdi
case EdgePanelKind::MARK:
SetLaneFreeMarker(lane, false);
break;
case EdgePanelKind::RESAMPLE:
// ⚠️ 漏掉这一支时 ✕ 点了没反应(落进 `default` 关掉的是「边沿计数」),
// 用户只能改走右键菜单取消功能(用户 2026-11-03 报告)。
SetLaneResample(lane, false);
break;
case EdgePanelKind::CLIP:
SetLaneClip(lane, false);
break;
case EdgePanelKind::COUNT:
default:
SetLaneEdgeCount(lane, false);
@@ -3438,7 +3573,7 @@ namespace jdi
//(上百个信号合并时必备)。
if (hoveredKind == EdgePanelKind::MEAN || hoveredKind == EdgePanelKind::CENTER ||
hoveredKind == EdgePanelKind::PEAK || hoveredKind == EdgePanelKind::COUNT ||
hoveredKind == EdgePanelKind::RESAMPLE)
hoveredKind == EdgePanelKind::RESAMPLE || hoveredKind == EdgePanelKind::CLIP)
{
const EdgePanelLayout layout = EdgePanelLayoutFor(panelLane, hoveredKind);
if (layout.content.Width > 0.0f &&
@@ -3459,6 +3594,8 @@ namespace jdi
? wheelLane.edgeCountScroll
: (hoveredKind == EdgePanelKind::RESAMPLE)
? wheelLane.resampleScroll
: (hoveredKind == EdgePanelKind::CLIP)
? wheelLane.clipScroll
: wheelLane.meanFilterScroll;
const float next = current - static_cast<float>(notches * 40.0 * m_scale);
const float target = std::max(0.0f, std::min(next, maxScroll));
@@ -3476,6 +3613,9 @@ namespace jdi
case EdgePanelKind::RESAMPLE:
SetLaneResampleScroll(panelLane, target);
break;
case EdgePanelKind::CLIP:
SetLaneClipScroll(panelLane, target);
break;
default:
SetLaneMeanFilterScroll(panelLane, target);
break;
+127 -21
View File
@@ -868,6 +868,8 @@ namespace jdi
boxes.Add(layout.opacityValue, 3);
boxes.Add(layout.showDelta, 3);
boxes.Add(layout.showEnum, 3);
boxes.Add(layout.clipBuildButton, 3);
boxes.Add(layout.clipDestructiveButton, 3);
boxes.AddAll(layout.rowChecks, 4);
boxes.AddAll(layout.rowPoints, 4);
@@ -1350,10 +1352,42 @@ namespace jdi
m_decorRects.clear();
m_useDecorCache = true;
// ⚠️ **网格次序拖拽在「显示布局边界」调试模式(`m_forceFullRepaint`)下必须在这里补上位移**。
// 上面的 `PaintLaneReorder` 快路径用静态场景贴图平移,而调试叠加层要求每帧重画边界框,
// 贴回来的旧像素会盖掉调试框,所以那条路径被 `m_forceFullRepaint` 排除;
// 但这里的整幅重绘若不套用 `LaneDragOffset`,被拖网格就**不跟随鼠标**、吸附槽位也
// 无从体现 —— 用户 2026-11-03 报告的"开启布局边界后只能盲拖"正是此因。
// 这里对每个网格临时套用位移重画,被拖网格最后画(压在最上层),再补画提示。
const bool laneReorderLive = (m_dragMode == PlotDragMode::LANE_REORDER) &&
m_laneDragFrom >= 0 &&
m_laneDragFrom < static_cast<int>(m_lanes.size());
if (laneReorderLive)
{
for (std::size_t index = 0; index < m_lanes.size(); ++index)
{
const int laneIndex = static_cast<int>(index);
if (laneIndex == m_laneDragFrom)
{
continue;
}
// 副本平移:`PaintLane` 只把 `rect` 当作几何来源(信号集合按 `laneIndex` 取),
// 因此整条网格(底色/网格线/波形/装饰)都会跟着一起移动。
LaneVisual shifted = m_lanes[index];
shifted.rect.Y += LaneDragOffset(laneIndex);
PaintLane(g, laneIndex, shifted, timeTicks);
}
const std::size_t from = static_cast<std::size_t>(m_laneDragFrom);
LaneVisual shifted = m_lanes[from];
shifted.rect.Y += LaneDragOffset(m_laneDragFrom);
PaintLane(g, m_laneDragFrom, shifted, timeTicks);
}
else
{
for (std::size_t index = 0; index < m_lanes.size(); ++index)
{
PaintLane(g, static_cast<int>(index), m_lanes[index], timeTicks);
}
}
PaintTimeAxis(g, timeTicks, timeStep);
@@ -1371,6 +1405,13 @@ namespace jdi
}
BlitDecorCache(g);
// 网格次序拖拽的吸附槽位 + 被拖网格轮廓(同一套代码,见 `PaintLaneReorderOverlay`):
// 必须画在装饰层**之后**,否则会被 GX 徽标/图例盖住。
if (laneReorderLive)
{
PaintLaneReorderOverlay(g);
}
// 此刻画布上正好是"绘图区底色 + 全部网格 + 时间轴",尚未绘制任何随鼠标变化的内容,
// 因此是捕获静态场景缓存的最佳时点(必须在裁剪区已全部恢复之后执行)。
// ⚠️ 快速帧(交互期间,关闭了抗锯齿)**仍然捕获**(这样拖动参数滑块时能走
@@ -1615,13 +1656,25 @@ namespace jdi
}
g.ReleaseHDC(target);
// 3) 目标位置的高亮提示与被拖网格的轮廓都**必须夹在网格区域内**绘制:
// 鼠标拖到工具栏/时间轴上时,若不裁剪,强调色边框会画到那些区域上。
// 3) 目标位置的高亮提示与被拖网格的轮廓。
PaintLaneReorderOverlay(g);
}
void PlotView::PaintLaneReorderOverlay(gd::Graphics& g)
{
if (m_dragMode != PlotDragMode::LANE_REORDER || m_laneDragFrom < 0 || m_lanes.empty())
{
return;
}
// 目标位置的高亮提示与被拖网格的轮廓都**必须夹在网格区域内**绘制:
// 鼠标拖到工具栏/时间轴上时,若不裁剪,强调色边框会画到那些区域上。
const int clipState = g.Save();
g.SetClip(m_plotRect, gd::CombineModeIntersect);
// 目标位置的高亮提示(吸附提示):在被拖网格将要落到的槽位上画一层强调色底 + 边框。
const float laneHeight = m_lanes.front().rect.Height;
if (laneHeight > 0.0f)
{
const int dropSlot = (m_laneDragTarget >= 0) ? m_laneDragTarget : m_laneDragFrom;
float slotTop = m_lanes.front().rect.Y + static_cast<float>(dropSlot) * laneHeight;
if (dropSlot > m_laneDragFrom)
@@ -1636,8 +1689,11 @@ namespace jdi
slot.Width - 2.0f * m_scale, slot.Height - 2.0f * m_scale),
3.0f * m_scale, m_theme->accent, 1.6f * m_scale);
}
}
// 被拖网格的轮廓:加强"整块网格被拎起"的观感。
if (m_laneDragFrom < static_cast<int>(m_lanes.size()))
{
const gd::RectF& dragged = m_lanes[static_cast<std::size_t>(m_laneDragFrom)].rect;
const float destTop = dragged.Y + LaneDragOffset(m_laneDragFrom);
const gd::RectF outline(dragged.X + 1.0f * m_scale, destTop + 1.0f * m_scale,
@@ -1646,8 +1702,8 @@ namespace jdi
outline.Width, outline.Height),
3.0f * m_scale, 0x22000000u);
UiDrawRoundRect(g, outline, 3.0f * m_scale, m_theme->accent, 1.8f * m_scale);
g.Restore(clipState);
}
g.Restore(clipState);
}
@@ -4067,6 +4123,10 @@ namespace jdi
mixRect(layout.button);
mixRect(layout.showDelta);
mixRect(layout.showEnum);
// 「选区截取」:底部两个按钮的矩形(双光标关闭时为空 ⇒ 自动区分两种状态)。
mixRect(layout.clipBuildButton);
mixRect(layout.clipDestructiveButton);
mixRect(layout.clipHint);
mixRect(layout.content);
mixRect(layout.tableHeader);
for (const EdgePanelLayout::TableColumn& column : layout.tableColumns)
@@ -4195,6 +4255,17 @@ namespace jdi
mix(intervalBits);
mix(resampleItem->collapsed ? 1ull : 0ull);
}
// 「选区截取」:只有勾选状态影响该行像素;
// 但底部按钮是否显示取决于「双光标」模式,双光标开关变化同样要让面板重绘。
mix(lane.ClipEnabled(ref) ? 1ull : 0ull);
}
// 「选区截取」的双光标状态决定底部按钮/提示的像素。
if (kind == EdgePanelKind::CLIP)
{
mix(Tab().CursorMode() == PlotCursorMode::DOUBLE ? 1ull : 0ull);
// ⚠️ 滚动偏移必须**按真实值**入键(不能退化成布尔):否则滚到 1px 之后
// 键不再变化 ⇒ 后续滚动不会重绘 ⇒ 表现为"滚不动 / 滚不到底"(用户 2026-11-03)。
mixFloat(lane.clipScroll);
}
// ⚠️⚠️ **边沿统计的数字本身就是面板内容,数字变化必须能让面板重绘**:
@@ -4883,13 +4954,14 @@ namespace jdi
const bool weakPeak = (kind == EdgePanelKind::PEAK);
const bool freeMarker = (kind == EdgePanelKind::MARK);
const bool resamplePanel = (kind == EdgePanelKind::RESAMPLE);
// ⚠️ 「均值滤波」「中心轴边沿计数」「弱峰调整」「重采样」= 可滚动的分组列表
const bool clipPanel = (kind == EdgePanelKind::CLIP);
// ⚠️ 「均值滤波」「中心轴边沿计数」「弱峰调整」「重采样」「选区截取」= 可滚动的分组列表
//(条目栏 + 展开后的参数行);
// **「边沿计数」= 表格**(表头:信号名 / 上升沿计数 / 下降沿计数),单独一支。
// 它们都**不能**再走旧的逐条目分支:那个分支没有任何裁切,滚动时会画到标题栏上。
const bool listStyle = (meanFilter || centerAxis || weakPeak || resamplePanel);
const bool listStyle = (meanFilter || centerAxis || weakPeak || resamplePanel || clipPanel);
const bool countTable = (kind == EdgePanelKind::COUNT);
const bool hasButton = (meanFilter || weakPeak || resamplePanel);
const bool hasButton = (meanFilter || weakPeak || resamplePanel || clipPanel);
{
if (index >= count)
{
@@ -5088,8 +5160,11 @@ namespace jdi
: (freeMarker ? std::wstring(L"自由标记")
: (resamplePanel
? std::wstring(kResampleTitle)
: ((centerAxis ? L"中心轴边沿计数" : L"边沿计数") +
EdgeRangeSuffix()))));
: (clipPanel
? std::wstring(kClipTitle)
: ((centerAxis ? L"中心轴边沿计数"
: L"边沿计数") +
EdgeRangeSuffix())))));
UiText(g, title, m_fonts->uiSmall,
gd::RectF(badgeRect.X + badgeRect.Width + 8.0f * m_scale, panel.Y,
panel.Width - badgeRect.Width - 8.0f * m_scale - titleHeight,
@@ -5347,13 +5422,15 @@ namespace jdi
: tabLane.FindMeanFilterItem(ref));
const Lane::ResampleItem* resampleItem =
resamplePanel ? tabLane.FindResampleItem(ref) : nullptr;
const bool checked = resamplePanel ? (resampleItem != nullptr)
const bool checked = clipPanel ? tabLane.ClipEnabled(ref)
: (resamplePanel ? (resampleItem != nullptr)
: (weakPeak ? (peakItem != nullptr)
: (item != nullptr));
: (item != nullptr)));
const bool rowCollapsed =
resamplePanel ? (resampleItem != nullptr && resampleItem->collapsed)
clipPanel ? true
: (resamplePanel ? (resampleItem != nullptr && resampleItem->collapsed)
: (weakPeak ? (peakItem != nullptr && peakItem->collapsed)
: (item != nullptr && item->collapsed));
: (item != nullptr && item->collapsed)));
// 内容区已由 `g.SetClip(layout.content)` 裁切(行可以“半截”显示,滚动更顺滑);
// 已经完整滚出可视范围的行在上面就 `continue` 了,这里不再额外门控。
@@ -5414,6 +5491,11 @@ namespace jdi
gd::RectF(row.X, row.Y, std::max(10.0f, labelWidth),
row.Height),
m_theme->textMuted, TextAlign::LEFT, layout.content);
// ⚠️ 统计数字**绝不省略**(用户 2026-11-03 要求):
// 面板宽度已按真实文本量宽(`EdgePanelWidth` 逐信号实测),
// 因此这里用 `TextAlign::RIGHT` + 完整矩形即可;
// 万一仍被窗口边界夹住,也宁可超出面板右缘(由内容区裁剪兜底),
// **不要**退化成 `…` 让用户看不到真实数值。
UiTextClipped(g, text, m_fonts->monoSmall,
gd::RectF(row.X + labelWidth + 8.0f * m_scale, row.Y,
std::max(10.0f, row.Width - labelWidth -
@@ -5458,16 +5540,21 @@ namespace jdi
minInterval = resampleItem->minInterval;
// 「最大 / 最小数据间隔」= 自适应单位的**小数**文本(绝不用科学计数法),
// 末尾追加一个括号内的「对应频率」尾缀(单位在 Hz/kHz/MHz/GHz 之间自适应)。
// ⚠️ 频率必须由**同一份舍入后的间隔**换算,否则两列会对不上
// (例如显示 `5us` 却给出 `199.99937 kHz`)。
//
// ⚠️⚠️ 全部使用**真实值**(用户 2026-11-03 明确要求):
// * 时间间隔:`FormatDurationCompound` 会把次级单位保留 3 位小数
// ⇒ `0.003696631s` 显示为 `3ms 696.631us`(旧实现舍入到 3 位有效数字
// 得到 `3ms 700us`,把真实尾数抹掉了);
// * 频率:由**未舍入的真实间隔**换算 ⇒ `270.517 Hz`(旧实现用舍入后的
// `3.70ms` 换算得到 `270.27 Hz`,与输入框不一致)。
// 两条路径都用真实值 ⇒ 统计行与输入框天然一致。
const auto intervalWithFrequency = [](double seconds) -> std::wstring
{
const double shown = RoundIntervalForDisplay(seconds);
std::wstring text = FormatDurationCompound(shown);
if (std::isfinite(shown) && shown > 0.0)
std::wstring text = FormatDurationCompound(seconds);
if (std::isfinite(seconds) && seconds > 0.0)
{
text += L"(";
text += FormatFrequencyText(1.0 / shown);
text += FormatFrequencyText(1.0 / seconds);
text += L")";
}
return text;
@@ -5500,8 +5587,8 @@ namespace jdi
}
if (rowIndex < layout.rowFrequencyField.size())
{
const double hertz =
(interval > 0.0) ? (1.0 / interval) : 0.0;
// ⚠️ 频率同样用**真实间隔**换算(与时间间隔行同源)⇒ `270.517 Hz`。
const double hertz = (interval > 0.0) ? (1.0 / interval) : 0.0;
// 非编辑态:频率单位自适应(Hz/kHz/MHz/GHz);编辑态:只显示 Hz 数值。
paintInputRow(layout.rowFrequencyField[rowIndex], kResampleFrequencyLabel,
(hertz > 0.0) ? FormatFrequencyText(hertz) : L"—",
@@ -6109,7 +6196,26 @@ namespace jdi
UiText(g, text, m_fonts->uiSmall, inner,
checked ? m_theme->textStrong : m_theme->text, TextAlign::CENTER);
};
if (hasButton)
if (clipPanel)
{
// 「选区截取」:底部两个并排按钮;未启用双光标时都给提示、不画按钮。
const bool doubleCursor = (Tab().CursorMode() == PlotCursorMode::DOUBLE);
if (doubleCursor)
{
paintToggleButton(layout.clipBuildButton, kClipBuildLabel, false);
paintToggleButton(layout.clipDestructiveButton, kClipDestructiveLabel, false);
}
else
{
// 提示文字不参与滚动,画在内容区正中。
// ⚠️ **绝不省略**(用户 2026-11-03 明确要求"要能够完整显示这条提示内容"):
// 面板宽度已按提示文字实测撑开(见 `EdgePanelWidth`),这里直接整段输出。
UiTextClipped(g, std::wstring(kClipNeedDoubleCursorHint), m_fonts->uiSmall,
layout.content, m_theme->textMuted, TextAlign::CENTER,
layout.content);
}
}
if (hasButton && !clipPanel)
{
paintToggleButton(layout.button, kBuildSignalLabel, false);
}
+146 -32
View File
@@ -29,12 +29,13 @@ namespace jdi
const bool meanFilter = (kind == EdgePanelKind::MEAN);
const bool weakPeak = (kind == EdgePanelKind::PEAK);
const bool resamplePanel = (kind == EdgePanelKind::RESAMPLE);
// 「均值滤波」「中心轴边沿计数」「弱峰调整」「重采样」= 可滚动的分组列表
const bool clipPanel = (kind == EdgePanelKind::CLIP);
// 「均值滤波」「中心轴边沿计数」「弱峰调整」「重采样」「选区截取」= 可滚动的分组列表
//(信号色叠底条目栏 + 右侧复选框 + 展开后的参数行);
// **「边沿计数」= 表格**(表头:信号名 / 上升沿计数 / 下降沿计数)。
// ⚠️ 旧式「逐条目」排版(无滚动的名称+参数行)已彻底删除:上百个信号时它会算出
// 上万像素高的面板,超出绘图区后**面板像素缓存一次都存不进去** ⇒ 每帧整块重画。
const bool listStyle = (centerAxis || meanFilter || weakPeak || resamplePanel);
const bool listStyle = (centerAxis || meanFilter || weakPeak || resamplePanel || clipPanel);
const bool countTable = (kind == EdgePanelKind::COUNT);
// 标题行 = “GX” 徽标 + 标题(“边沿计数” / “中心轴边沿计数” / “均值滤波” / “弱峰调整”
@@ -168,18 +169,34 @@ namespace jdi
(kFreeMarkerScrollWidth + 4.0f) * m_scale);
if (resamplePanel)
{
// 「重采样」四行 = 「标签列 + 数值列」(两行统计 + 两行输入框),
// 标签取四者中最宽的一个,数值列按间隔 / 频率的典型文本宽度取。
// ⚠️ 「最大/最小数据间隔」行末尾还带「(频率)」尾缀(用户 2026-11-03 要求),
// 取值列必须按**含尾缀**的文本量宽,否则尾缀会被裁掉。
// 「重采样」四行 = 「标签列 + 数值列」(两行统计 + 两行输入框)。
// 标签取四者中最宽的一个;数值列必须按**真实会显示的文本**量宽
// (用户 2026-11-03 要求:统计信息不能被省略成 `…`)。
const float resampleLabelColumn =
std::max(std::max(UiMeasureTextWidth(m_fonts->uiSmall, kResampleMaxIntervalLabel),
UiMeasureTextWidth(m_fonts->uiSmall, kResampleMinIntervalLabel)),
std::max(UiMeasureTextWidth(m_fonts->uiSmall, kResampleIntervalLabel),
UiMeasureTextWidth(m_fonts->uiSmall, kResampleFrequencyLabel)));
const float resampleValueColumn =
std::max(std::max(UiMeasureTextWidth(m_fonts->monoSmall, L"1.23456 GHz"),
UiMeasureTextWidth(m_fonts->monoSmall, L"1h 1min 2s")),
// ⚠️ 逐信号实测「最大 / 最小数据间隔」的真实文本宽度(含(频率)尾缀),
// 而不是按 `1h 1min 2s(1.23456 GHz)` 这类**典型样例**估算 ——
// 真实值(如 `16ms 182.614us(61.795 Hz)`)往往更长,估算不足就会被截断。
float resampleValueColumn = 0.0f;
for (const SignalRef& ref : lane.signals)
{
const Lane::ResampleItem* item = lane.FindResampleItem(ref);
if (item == nullptr)
{
continue;
}
resampleValueColumn = std::max(
resampleValueColumn,
UiMeasureTextWidth(m_fonts->monoSmall, FormatIntervalWithFrequency(item->maxInterval)));
resampleValueColumn = std::max(
resampleValueColumn,
UiMeasureTextWidth(m_fonts->monoSmall, FormatIntervalWithFrequency(item->minInterval)));
}
// 兜底下限:没有勾选任何信号时也保持一个可用的最小宽度。
resampleValueColumn = std::max(resampleValueColumn,
UiMeasureTextWidth(m_fonts->monoSmall, L"1h 1min 2s(1.23456 GHz)"));
width = std::max(width, kEdgePanelPad * 2.0f * m_scale + resampleLabelColumn +
8.0f * m_scale + resampleValueColumn +
@@ -205,6 +222,24 @@ namespace jdi
width = std::max(width, kEdgePanelPad * 2.0f * m_scale + buttonWidth);
}
if (clipPanel)
{
// 「选区截取」底部是**两个并排**按钮,宽度必须容得下两段文字 + 间隙 + 内外边距。
const float gap = 6.0f * m_scale;
const float twoButtons =
(UiMeasureTextWidth(m_fonts->uiSmall, kClipBuildLabel) + 28.0f * m_scale) + gap +
(UiMeasureTextWidth(m_fonts->uiSmall, kClipDestructiveLabel) + 28.0f * m_scale);
width = std::max(width, kEdgePanelPad * 2.0f * m_scale + twoButtons);
// ⚠️ **未启用双光标**时面板只显示一行提示:宽度必须容得下它的**完整**文本
//(用户 2026-11-03 明确要求"能够完整显示这条提示内容",不能被省略成 `…`)。
if (Tab().CursorMode() != PlotCursorMode::DOUBLE)
{
const float hintWidth = UiMeasureTextWidth(m_fonts->uiSmall, kClipNeedDoubleCursorHint);
width = std::max(width, kEdgePanelPad * 2.0f * m_scale + hintWidth +
(kFreeMarkerScrollWidth + 4.0f) * m_scale);
}
}
if (countTable)
{
// 「边沿计数」表格:右侧要给内容滚动条留位(上百个信号时内容必然超高)。
@@ -300,25 +335,31 @@ namespace jdi
const bool weakPeak = (kind == EdgePanelKind::PEAK);
const bool freeMarker = (kind == EdgePanelKind::MARK);
const bool resamplePanel = (kind == EdgePanelKind::RESAMPLE);
// 「均值滤波」「中心轴边沿计数」「弱峰调整」「重采样」共用同一套**可滚动分组列表**排布
//(信号色叠底条目栏 + 展开后的参数行);「边沿计数」自 2026-11-02 起走**表格**分支。
const bool listStyle = (meanFilter || centerAxis || weakPeak || resamplePanel);
const bool clipPanel = (kind == EdgePanelKind::CLIP);
// 「均值滤波」「中心轴边沿计数」「弱峰调整」「重采样」「选区截取」共用同一套
//**可滚动分组列表**排布(信号色叠底条目栏 + 展开后的参数行);
//「边沿计数」自 2026-11-02 起走**表格**分支。
//⚠️ 「选区截取」的条目**不可展开**(只需右侧复选框),因此只占一行。
const bool listStyle = (meanFilter || centerAxis || weakPeak || resamplePanel || clipPanel);
const bool countTable = (kind == EdgePanelKind::COUNT);
const bool hasButton = (meanFilter || weakPeak || resamplePanel);
const bool hasButton = (meanFilter || weakPeak || resamplePanel || clipPanel);
const bool enabled = meanFilter ? lane.meanFilter
: (weakPeak ? lane.weakPeak
: (freeMarker ? lane.freeMarker
: (resamplePanel ? lane.resample
: (clipPanel ? lane.clip
: (centerAxis ? lane.edgeCenter
: lane.edgeCount))));
: lane.edgeCount)))));
const bool open = meanFilter ? lane.meanFilterPanel.open
: (weakPeak ? lane.weakPeakPanel.open
: (freeMarker ? lane.freeMarkerPanel.open
: (resamplePanel
? lane.resamplePanel.open
: (clipPanel
? lane.clipPanel.open
: (centerAxis
? lane.edgeCenterPanel.open
: lane.edgePanel.open))));
: lane.edgePanel.open)))));
if (!enabled || !open)
{
return layout;
@@ -329,9 +370,11 @@ namespace jdi
: (freeMarker ? lane.freeMarkerPanel.collapsed
: (resamplePanel
? lane.resamplePanel.collapsed
: (clipPanel
? lane.clipPanel.collapsed
: (centerAxis
? lane.edgeCenterPanel.collapsed
: lane.edgePanel.collapsed))));
: lane.edgePanel.collapsed)))));
layout.collapsed = collapsed;
int rowCount = 0;
@@ -356,10 +399,17 @@ namespace jdi
// 因此不再有独立的底部编辑行;「边沿计数」走独立的**表格**分支。
const float buttonHeight = hasButton ? (kEdgePanelButtonHeight * m_scale) : 0.0f;
// 面板底部(内容区之下)占的纵向空间:有按钮时 = pad + 按钮 + pad,无按钮时只有 pad。
const float footerHeight = hasButton ? (buttonHeight + pad) : 0.0f;
// ⚠️⚠️ 「选区截取」的两个按钮(截取为新信号 / 原始信号截取)是**并排在同一行**的
// (见下方的几何赋值),因此 footer 只能按**一行**算。旧实现按 `2 * 按钮高 + 6`
// 预留,而实际只画出并排的一行 ⇒ 内容区下方多出一整行“无意义的空白”
// (用户 2026-11-03 连续三轮报告的那片空白,根因就在这一行)。
// ⚠️ **未启用双光标**时两个按钮都不显示 ⇒ footer 归零(否则面板会留一大段空白)。
const bool clipReady = clipPanel && (Tab().CursorMode() == PlotCursorMode::DOUBLE);
const float footerHeight =
clipPanel ? (clipReady ? (buttonHeight + pad) : 0.0f)
: (hasButton ? (buttonHeight + pad) : 0.0f);
const float bottomPad = pad;
const float weakPeakSliderRow = kWeakPeakSliderRowHeight * m_scale;
float meanContentHeight = 0.0f;
float height = 0.0f;
float tableHeaderHeight = 0.0f;
@@ -387,8 +437,19 @@ namespace jdi
// 「均值滤波」与「弱峰调整」= 两行滑块。
// 内容超高时出现滚动条;底部「构建为新信号」按钮固定不滚动。
const float groupRow = kEdgePanelMeanRowHeight * m_scale;
const float sliderRow = weakPeakSliderRow;
const float sliderRow = kWeakPeakSliderRowHeight * m_scale;
// ⚠️ 「选区截取」在**未启用双光标**时没有任何可操作内容(只有一行提示文字),
// 因此不按信号数累加行高、也不套用「至少 3 行」的下限
// (用户 2026-11-03 报告:未启用双光标时仍显示信号条目、且能触发滚动)。
const bool clipReady = clipPanel && (Tab().CursorMode() == PlotCursorMode::DOUBLE);
float content = 0.0f;
if (clipPanel && !clipReady)
{
// 提示行固定占一行高度(绘制时在内容区居中显示)。
content = kEdgePanelMeanRowHeight * m_scale;
}
else
{
for (const SignalRef& ref : lane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
@@ -397,6 +458,10 @@ namespace jdi
continue;
}
content += groupRow;
if (clipPanel)
{
continue; // 「选区截取」的条目**不可展开**,每条只占一行
}
bool expanded = false;
if (resamplePanel)
{
@@ -435,12 +500,15 @@ namespace jdi
}
}
}
}
meanContentHeight = content;
const float budget =
m_plotRect.Height - (titleHeight + pad + bottomPad + footerHeight);
// ⚠️ 高度必须**精确等于内容高度**(不超过可用预算),不要再套「至少 3 行」的下限:
// 那个下限会让只有 1 个信号的面板留出 2 行空白(用户 2026-11-03 报告
// “信号条目的滚动区域与下方按钮之间有一大段空白”)。
const float maxContent =
std::max(3.0f * groupRow,
std::min(kFreeMarkerContentMaxHeight * m_scale, std::max(3.0f * groupRow, budget)));
std::min(kFreeMarkerContentMaxHeight * m_scale, std::max(groupRow, budget));
const float view = std::min(content, maxContent);
height = titleHeight + pad + view + bottomPad + footerHeight;
}
@@ -534,17 +602,21 @@ namespace jdi
: (freeMarker ? lane.freeMarkerPanel.x
: (resamplePanel
? lane.resamplePanel.x
: (clipPanel
? lane.clipPanel.x
: (centerAxis
? lane.edgeCenterPanel.x
: lane.edgePanel.x))));
: lane.edgePanel.x)))));
const double storedY = meanFilter ? lane.meanFilterPanel.y
: (weakPeak ? lane.weakPeakPanel.y
: (freeMarker ? lane.freeMarkerPanel.y
: (resamplePanel
? lane.resamplePanel.y
: (clipPanel
? lane.clipPanel.y
: (centerAxis
? lane.edgeCenterPanel.y
: lane.edgePanel.y))));
: lane.edgePanel.y)))));
int openPanelOrdinal = 0;
{
// ⚠️ 错开量用「**当前真正显示出来的面板**中排在本面板之前的个数」:
@@ -570,12 +642,15 @@ namespace jdi
return lane.freeMarker && lane.freeMarkerPanel.open;
case EdgePanelKind::RESAMPLE:
return lane.resample && lane.resamplePanel.open;
case EdgePanelKind::CLIP:
return lane.clip && lane.clipPanel.open;
}
return false;
};
static const EdgePanelKind kOrder[6] = {EdgePanelKind::COUNT, EdgePanelKind::CENTER,
static const EdgePanelKind kOrder[7] = {EdgePanelKind::COUNT, EdgePanelKind::CENTER,
EdgePanelKind::MEAN, EdgePanelKind::PEAK,
EdgePanelKind::MARK, EdgePanelKind::RESAMPLE};
EdgePanelKind::MARK, EdgePanelKind::RESAMPLE,
EdgePanelKind::CLIP};
int openSeen = 0;
bool found = false;
for (std::size_t lane = 0; lane < m_lanes.size() && !found; ++lane)
@@ -737,7 +812,22 @@ namespace jdi
// 「均值滤波 / 弱峰调整」:可滚动内容区(信号色叠底条目栏 + 展开后的两行滑块)
// +(均值滤波 / 弱峰调整)底部按钮。
const float buttonRowHeight = kEdgePanelButtonHeight * m_scale;
if (hasButton)
if (clipPanel)
{
// 「选区截取」:底部**两个**并排的固定按钮(不参与滚动)。
// ⚠️ 未启用双光标模式时两者都是空矩形 ⇒ 不可点(面板只显示提示文字)。
const bool doubleCursor = (Tab().CursorMode() == PlotCursorMode::DOUBLE);
if (doubleCursor)
{
const float gap = 6.0f * m_scale;
const float halfWidth = std::max(0.0f, (width - pad * 2.0f - gap) * 0.5f);
const float buttonTop = top + height - pad - buttonRowHeight;
layout.clipBuildButton = gd::RectF(left + pad, buttonTop, halfWidth, buttonRowHeight);
layout.clipDestructiveButton =
gd::RectF(left + pad + halfWidth + gap, buttonTop, halfWidth, buttonRowHeight);
}
}
else if (hasButton)
{
layout.button = gd::RectF(left + pad, top + height - pad - buttonRowHeight,
std::max(0.0f, width - pad * 2.0f), buttonRowHeight);
@@ -746,15 +836,26 @@ namespace jdi
const float groupRow = kEdgePanelMeanRowHeight * m_scale;
const float sliderRow = kWeakPeakSliderRowHeight * m_scale;
const float scrollW = kFreeMarkerScrollWidth * m_scale;
const float footerHeight = hasButton ? (buttonRowHeight + pad) : 0.0f;
const bool clipReadyHere =
clipPanel && (Tab().CursorMode() == PlotCursorMode::DOUBLE);
// ⚠️ 两处(高度算式与这里)必须同为**一行按钮**高:两个按钮并排,不是上下两行。
const float footerHeight =
clipPanel ? (clipReadyHere ? (buttonRowHeight + pad) : 0.0f)
: (hasButton ? (buttonRowHeight + pad) : 0.0f);
// ⚠️⚠️ 内容区高度必须与高度算式
// `height = titleHeight + pad + view + bottomPad + footerHeight` **严格同源**:
// 即 `viewHeight = height - titleHeight - pad - bottomPad - footerHeight`。
// 少减 `bottomPad` 会让内容区比实际可用高度多出一个 pad ⇒
// 条目区下方与按钮之间留出一片空白(用户 2026-11-03 两次报告)。
const float viewHeight =
std::max(0.0f, height - titleHeight - pad - pad - footerHeight);
std::max(0.0f, height - titleHeight - pad - bottomPad - footerHeight);
const bool scrollable = meanContentHeight > viewHeight + 0.5f;
const float maxScroll = std::max(0.0f, meanContentHeight - viewHeight);
const float storedScroll = meanFilter ? lane.meanFilterScroll
: (weakPeak ? lane.weakPeakScroll
: (resamplePanel ? lane.resampleScroll
: lane.edgeCenterScroll));
: (clipPanel ? lane.clipScroll
: lane.edgeCenterScroll)));
const float scroll = std::max(0.0f, std::min(storedScroll, maxScroll));
const float innerWidth =
std::max(0.0f, width - pad * 2.0f - (scrollable ? (scrollW + 4.0f * m_scale) : 0.0f));
@@ -803,8 +904,15 @@ namespace jdi
(resetLeft - resetGap - valueColumn - 6.0f * m_scale) - trackLeft);
float rowY = layout.content.Y - scroll;
// ⚠️ 「选区截取」在**未启用双光标**时不生成任何信号行:面板只显示一句居中提示
// (用户 2026-11-03 明确要求:不应显示信号名条目、更不应能滚动)。
const bool clipRowsVisible = !clipPanel || clipReadyHere;
for (const SignalRef& ref : lane.signals)
{
if (!clipRowsVisible)
{
break;
}
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || !resolved.visible)
{
@@ -812,7 +920,12 @@ namespace jdi
}
bool checked = false;
bool itemCollapsed = false;
if (resamplePanel)
if (clipPanel)
{
// 「选区截取」:只有勾选状态,条目**不可展开**(用户 2026-11-03 要求)。
checked = lane.ClipEnabled(ref);
}
else if (resamplePanel)
{
const Lane::ResampleItem* item = lane.FindResampleItem(ref);
checked = (item != nullptr);
@@ -842,7 +955,8 @@ namespace jdi
gd::RectF(contentLeft, rowY, innerWidth, groupRow));
layout.rowNameRects.push_back(gd::RectF(contentLeft, rowY, innerWidth, groupRow));
layout.rowTriangles.push_back(
checked ? gd::RectF(contentLeft + 3.0f * m_scale,
(checked && !clipPanel)
? gd::RectF(contentLeft + 3.0f * m_scale,
rowY + (groupRow - kEdgePanelRowTriangleSize * m_scale) * 0.5f,
kEdgePanelRowTriangleSize * m_scale,
kEdgePanelRowTriangleSize * m_scale)
@@ -856,7 +970,7 @@ namespace jdi
rowY += groupRow;
// 参数行:只有勾选且未收起时才有几何(未勾选的行两者都是空矩形)。
if (!checked || itemCollapsed)
if (!checked || itemCollapsed || clipPanel)
{
layout.rowLevelTracks.push_back(gd::RectF());
layout.rowLevelValues.push_back(gd::RectF());
+25 -1
View File
@@ -80,6 +80,13 @@ namespace jdi
* 勾选状态通过 `UpdateItemLabel` 回写。默认 false(点击即关):与既有行为一致。
*/
bool keepOpen = false;
/**
* @brief 二级子菜单可滚动条目区的**最大显示行数**(0 = 不限制)。
*
* 与 `MenuLayoutConstraint::maxScrollRows` 同义,只是作用于该子菜单自身:
* 分组/网格数量可能上百,不限高时子菜单会长到屏幕之外。
*/
int submenuMaxRows = 0;
};
/**
@@ -245,7 +252,12 @@ namespace jdi
* @brief 菜单是否因内容过长而启用了滚动(条目区可滚动、前导标题与尾部条目固定)。
* @return 启用滚动返回 true。
*/
bool IsScrollable() const { return m_open && m_scrollEnabled; }
bool IsScrollable() const
{
// 二级子菜单展开且自身可滚动时同样算「可滚动」:宿主据此把滚轮转给菜单,
// 而不是"滚轮即关闭菜单"(否则「移动到…」这类长短不定的子菜单无法翻页)。
return m_open && (m_scrollEnabled || (m_subOpen && m_subScrollEnabled));
}
/**
* @brief 滚动条**轨道**矩形(仅可滚动菜单有效,其余返回空矩形)。
@@ -568,6 +580,18 @@ namespace jdi
std::vector<gd::RectF> m_subItemRects; /**< 子菜单各项矩形。 */
int m_subHoverIndex = -1; /**< 子菜单当前悬停项。 */
int m_subPressedIndex = -1; /**< 子菜单按下时的项下标。 */
/**
* @brief 子菜单的可滚动状态(条目过多时启用)。
*
* 与主菜单的滚动机制**刻意保持独立**:子菜单在主菜单展开期间一直存在,
* 若共用一套偏移,主菜单滚动会连带把子菜单的条目位置打乱。
*/
bool m_subScrollEnabled = false; /**< 子菜单条目区是否可滚动。 */
float m_subScrollOffset = 0.0f; /**< 子菜单滚动偏移(像素,0 = 顶部)。 */
float m_subScrollRegionHeight = 0.0f; /**< 子菜单内容总高(像素)。 */
gd::RectF m_subViewport; /**< 子菜单条目可视区(同时是条目裁剪矩形)。 */
gd::RectF m_subScrollBarRect; /**< 子菜单滚动条轨道矩形。 */
float m_subMaxRowsHeight = 0.0f; /**< 子菜单可视区高度上限(0 = 不限制)。 */
};
} // namespace jdi
+77 -4
View File
@@ -415,7 +415,21 @@ namespace jdi
bool PopupMenu::OnMouseWheel(int delta)
{
if (!m_open || !m_scrollEnabled || delta == 0)
if (!m_open || delta == 0)
{
return false;
}
// 子菜单展开且可滚动时优先滚子菜单:鼠标此时一定悬停在子菜单条目上
//(父项悬停即展开,滚轮自然是"翻这个列表"的意图)。
if (m_subOpen && m_subScrollEnabled)
{
const float previous = m_subScrollOffset;
m_subScrollOffset -= static_cast<float>(delta) / 120.0f * 54.0f * m_scale;
// 重新排布(内部会夹取 `m_subScrollOffset` 并刷新条目矩形)。
BuildSubLayout(m_subParentIndex);
return std::fabs(m_subScrollOffset - previous) > 0.01f;
}
if (!m_scrollEnabled)
{
return false;
}
@@ -447,10 +461,20 @@ namespace jdi
m_subItemRects.clear();
m_subHoverIndex = -1;
m_subPressedIndex = -1;
m_subScrollEnabled = false;
m_subScrollOffset = 0.0f;
m_subScrollRegionHeight = 0.0f;
m_subViewport = gd::RectF();
m_subScrollBarRect = gd::RectF();
m_subMaxRowsHeight = 0.0f;
}
void PopupMenu::BuildSubLayout(int parentIndex)
{
// `CloseSubmenu()` 会清零全部子菜单状态,但滚动(`OnMouseWheel`)需要重新排布时
// **保留滚动位置**:只有切换到另一个父项才从顶部开始。
const float savedScrollOffset = m_subScrollOffset;
const int savedParentIndex = m_subParentIndex;
CloseSubmenu();
if (parentIndex < 0 || parentIndex >= static_cast<int>(m_items.size()))
{
@@ -463,6 +487,13 @@ namespace jdi
}
m_subItems = parent.submenu;
// 同一父项重新排布(滚动)时沿用原来的滚动位置。
m_subScrollOffset = (savedParentIndex == parentIndex) ? savedScrollOffset : 0.0f;
m_subScrollEnabled = false;
m_subScrollRegionHeight = 0.0f;
m_subViewport = gd::RectF();
m_subScrollBarRect = gd::RectF();
m_subMaxRowsHeight = 0.0f;
// 与主菜单一致:宽度取条目中的最大者并夹在上下限之间。
float contentWidth = kMinWidth * m_scale;
@@ -502,8 +533,28 @@ namespace jdi
}
contentWidth = std::min(contentWidth, kMaxWidth * m_scale);
// ------------------------------------------------------------------
// 子菜单条目过多时可滚动(`MenuItem::submenuMaxRows`)。
// 与主菜单的"头部固定 / 尾部固定"不同,这里**整段一起滚**:
// 子菜单的条目形态单一(分组列表 + 末段分隔线 + 新建分组),整体滚动即可,
// 且不会出现"两个固定区各占一半高度、中间只剩一两行"的糟糕观感。
// ------------------------------------------------------------------
const float subContentHeight = totalHeight - kPadV * 2.0f * m_scale;
if (parent.submenuMaxRows > 0)
{
const float rowsLimit =
static_cast<float>(parent.submenuMaxRows) * kRowHeight * m_scale;
m_subMaxRowsHeight = rowsLimit;
if (rowsLimit >= kRowHeight * m_scale && subContentHeight > rowsLimit + 0.5f)
{
m_subScrollEnabled = true;
m_subScrollRegionHeight = subContentHeight;
}
}
const float width = contentWidth;
const float height = totalHeight;
const float height =
m_subScrollEnabled ? (m_subMaxRowsHeight + kPadV * 2.0f * m_scale) : totalHeight;
const gd::RectF& parentRect = m_itemRects[static_cast<std::size_t>(parentIndex)];
const gd::RectF& bounds = m_buildBounds;
@@ -521,12 +572,28 @@ namespace jdi
m_subRect = gd::RectF(left, top, width, height);
m_subItemRects.assign(m_subItems.size(), gd::RectF());
float cursorY = top + kPadV * m_scale;
// 条目可视区:整段滚动模式下就是内容区;让出右侧滚动条的位置。
const float subScrollReserve =
m_subScrollEnabled ? (kScrollBarWidth + kScrollBarGap) * m_scale : 0.0f;
m_subViewport = gd::RectF(left + kPadH * m_scale, top + kPadV * m_scale,
std::max(0.0f, width - kPadH * 2.0f * m_scale - subScrollReserve),
m_subScrollEnabled ? m_subMaxRowsHeight : std::max(0.0f, height - kPadV * 2.0f * m_scale));
if (m_subScrollEnabled)
{
// 偏移夹到合法范围(内容高 - 可视高)。
m_subScrollOffset = std::max(
0.0f, std::min(m_subScrollOffset, std::max(0.0f, subContentHeight - m_subMaxRowsHeight)));
m_subScrollBarRect =
gd::RectF(left + width - kPadH * m_scale - kScrollBarWidth * m_scale,
top + kPadV * m_scale, kScrollBarWidth * m_scale, m_subMaxRowsHeight);
}
float cursorY = top + kPadV * m_scale - m_subScrollOffset;
for (std::size_t index = 0; index < m_subItems.size(); ++index)
{
const float itemHeight = ItemHeight(m_subItems[index]);
m_subItemRects[index] = gd::RectF(left + kPadH * m_scale, cursorY,
std::max(0.0f, width - kPadH * 2.0f * m_scale), itemHeight);
std::max(0.0f, m_subViewport.Width), itemHeight);
cursorY += itemHeight;
}
@@ -544,6 +611,12 @@ namespace jdi
}
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
// 滚动中的子菜单:可视区之外的条目矩形仍然存在(只是被裁掉),必须先按视口过滤,
// 否则滚出视野的条目还能被点中(表现为"点到看不见的东西")。
if (m_subScrollEnabled && !UiPointInRect(m_subViewport, fx, fy))
{
return -1;
}
for (std::size_t index = 0; index < m_subItems.size(); ++index)
{
const MenuItem& item = m_subItems[index];
+38
View File
@@ -305,6 +305,16 @@ namespace jdi
UiFillRoundRect(g, m_subRect, kCorner * m_scale, m_theme->cardBg);
UiDrawRoundRect(g, m_subRect, kCorner * m_scale, m_theme->borderStrong, 1.0f);
// 滚动中的子菜单:条目必须裁剪在可视区内(否则会画到面板圆角之外)。
int subClipState = -1;
if (m_subScrollEnabled)
{
subClipState = g.Save();
g.SetClip(gd::RectF(m_subRect.X + 1.0f * m_scale, m_subRect.Y + 1.0f * m_scale,
m_subRect.Width - 2.0f * m_scale, m_subRect.Height - 2.0f * m_scale),
gd::CombineModeIntersect);
}
for (std::size_t index = 0; index < m_subItems.size(); ++index)
{
const MenuItem& item = m_subItems[index];
@@ -395,6 +405,34 @@ namespace jdi
gd::RectF(textLeft, rect.Y, std::max(8.0f, textWidth), rect.Height), textColor,
TextAlign::LEFT);
}
if (m_subScrollEnabled)
{
if (subClipState >= 0)
{
g.Restore(subClipState);
}
// 滚动条:轨道 + 滑块(与主菜单同一套配色与算法,粗细取 `kScrollBarWidth`)。
if (m_subScrollBarRect.Width > 0.0f && m_subScrollRegionHeight > 0.0f &&
m_subViewport.Height > 0.0f)
{
const float radius = kScrollBarWidth * m_scale * 0.5f;
UiFillRoundRect(g, m_subScrollBarRect, radius, m_theme->gridWeak);
const float viewportHeight = m_subViewport.Height;
const float ratio = std::min(1.0f, viewportHeight / m_subScrollRegionHeight);
const float thumbHeight =
std::max(kScrollBarMinThumb * m_scale, m_subScrollBarRect.Height * ratio);
const float maxScroll = std::max(0.0f, m_subScrollRegionHeight - viewportHeight);
const float progress = (maxScroll > 0.0f) ? (m_subScrollOffset / maxScroll) : 0.0f;
const float thumbTop =
m_subScrollBarRect.Y +
std::max(0.0f, std::min(1.0f, progress)) *
std::max(0.0f, m_subScrollBarRect.Height - thumbHeight);
UiFillRoundRect(
g, gd::RectF(m_subScrollBarRect.X, thumbTop, m_subScrollBarRect.Width, thumbHeight),
radius, m_theme->borderStrong);
}
}
}
} // namespace jdi
+123 -1
View File
@@ -28,12 +28,27 @@ namespace jdi
enum MenuCommand : int
{
CMD_SIGNAL_SOLO = 2, /**< 仅显示该信号。 */
CMD_SIGNAL_CLONE = 5, /**< 克隆该信号(应用「调整」并烘进采样值,时间轴偏移保留)。 */
CMD_SIGNAL_CLONE = 5, /**< 克隆该信号(原始数据,只沿用源信号的时间轴偏移)。 */
CMD_SIGNAL_CLONE_ADJUSTED = 6, /**< 克隆该信号(带调整):沿用源信号的完整「调整」参数,不烘值。 */
CMD_GROUP_SHOW_ALL = 10, /**< 选中该分组全部信号。 */
CMD_GROUP_HIDE_ALL = 11, /**< 取消该分组全部信号。 */
CMD_GROUP_REMOVE = 12, /**< 从工作区移除该分组。 */
CMD_LANE_BASE = 300, /**< 网格归属基准(配合下标)。 */
CMD_LANE_NEW = 400, /**< 新建网格。 */
/**
* @brief 「移动到…」二级菜单里的分组条目基准(配合分组下标)。
*
* 与 `CMD_LANE_BASE` 同一套约定:`id - CMD_GROUP_MOVE_BASE` 即目标分组下标。
* 取值区间必须与 `CMD_LANE_BASE` 的区间(300–399)和 `CMD_LANE_NEW` 互不重叠。
*/
CMD_GROUP_MOVE_BASE = 600,
/**
* @brief 「移动到…」二级菜单底部:新建分组并把信号移进去。
*
* ⚠️ 与 `CMD_GROUP_MOVE_BASE` 之间留足空间(600–949 = 最多 350 个分组条目),
* 否则分组数量一多,`CMD_GROUP_MOVE_BASE + index` 就会撞上本命令。
*/
CMD_GROUP_MOVE_NEW = 950,
CMD_GAIN_EDIT = 500, /**< 「系数」二级面板里的系数输入框(幂等提交)。 */
CMD_GAIN_RESET = 501, /**< 「系数调整」标题栏右侧的还原按钮:把系数重置为 1。 */
CMD_OFFSET_RESET = 502, /**< 「偏移调整」标题栏右侧的还原按钮:两个偏移都恢复为默认值。 */
@@ -107,6 +122,15 @@ namespace jdi
m_pendingClosePart = RowPart::NONE;
m_pendingFilterClear = false;
m_groupNameClickRow = -1;
// 拖拽排序同样保存着行下标与逐行位移,行集合一变就必须整体作废。
m_signalDragPending = false;
m_signalDragging = false;
m_signalDragRow = -1;
m_signalDragDropGroup = -1;
m_signalDragDropIndex = -1;
m_signalDragDropRow = -1;
m_signalDragShift.clear();
m_signalDragShiftCur.clear();
}
/**
@@ -472,6 +496,18 @@ namespace jdi
/** @brief 分组折起/展开动画是否正在进行。 */
bool GroupAnimationActive() const { return !m_groupAnims.empty(); }
/**
* @brief 推进一步「信号拖拽避让」动画(由宿主定时器驱动)。
* @return 仍有行在移动 / 正在拖拽(宿主应保持定时重绘)返回 true。
*/
bool AdvanceSignalDragAnimation();
/** @brief 信号拖拽(含避让动画)是否仍在进行。 */
bool SignalDragAnimationActive() const
{
return m_signalDragPending || m_signalDragging;
}
/**
* @brief 供绘图区联动高亮的「悬停信号」。
*
@@ -488,6 +524,49 @@ namespace jdi
int TargetLaneIndex() const;
private:
/**
* @brief 开始跟踪一次可能变成拖拽的按下(信号行主体)。
* @param [IN] ref 目标信号。
* @param [IN] row 该信号所在的行下标。
* @param [IN] x 按下点横坐标。 @param [IN] y 按下点纵坐标。
*/
void BeginSignalDrag(const SignalRef& ref, int row, int x, int y);
/**
* @brief 拖拽中:更新被拖条目的跟随位置与避让目标。
* @param [IN] x 鼠标横坐标。 @param [IN] y 鼠标纵坐标。
* @return 需要重绘返回 true。
*/
bool UpdateSignalDrag(int x, int y);
/**
* @brief 重新计算拖拽的落点(目标分组 / 组内下标)与各行的避让位移目标。
*/
void UpdateSignalDragTargets();
/**
* @brief 取消拖拽(不发任何命令)。
*/
void CancelSignalDrag();
/**
* @brief 结束拖拽并**提交**重排(落点与源位置不同才提交)。
*/
void FinishSignalDrag();
/**
* @brief 取某行的避让位移(拖拽中);未拖拽时返回 0。
* @param [IN] row 行下标。
* @return 纵向位移(像素,负值 = 上移)。
*/
float SignalDragRowShiftFor(int row) const;
/**
* @brief 绘制被拖拽条目的「浮起」副本(在行循环之后绘制,因此始终在最上层)。
* @param [IN,OUT] g 绘图上下文。
*/
void PaintSignalDragGhost(gd::Graphics& g);
/**
* @brief 样式区与列表行中可进行内联文本编辑的字段。
*/
@@ -1445,6 +1524,34 @@ namespace jdi
*/
std::vector<float> m_rowShift;
// ------------------------------------------------------------------
// 信号条目拖拽排序(用户 2026-11-03 要求):
// * 左键按住信号行主体 → 超过阈值后进入拖拽;
// * 被拖条目**脱离原位置**跟随鼠标绘制(半透明浮起 + 阴影);
// * 其它信号行 / 分组行按插入位置做**避让位移动画**(`m_signalDragShiftCur` 逐帧插值);
// * 松手时提交到宿主(`kMessageReorderSignal`),由数据层重排并修正全部引用。
// ------------------------------------------------------------------
bool m_signalDragPending = false; /**< 已按下信号行主体、尚未越过拖拽阈值。 */
bool m_signalDragging = false; /**< 正在拖拽信号条目。 */
SignalRef m_signalDragRef; /**< 被拖拽的信号。 */
int m_signalDragRow = -1; /**< 被拖拽行在 `m_rows` 中的下标。 */
int m_signalDragStartX = 0; /**< 按下点(阈值判定用)。 */
int m_signalDragStartY = 0;
int m_signalDragMouseY = 0; /**< 最近一次鼠标纵坐标(被拖条目跟随它)。 */
int m_signalDragDropGroup = -1; /**< 当前目标分组下标(-1 = 无效)。 */
int m_signalDragDropIndex = -1; /**< 目标分组内的插入下标。 */
int m_signalDragDropRow = -1; /**< 插入位置对应的行下标(插入指示线画在这里)。 */
bool m_signalDragDropBefore = false; /**< 插入点在该行的上方(否则下方)。 */
std::vector<float> m_signalDragShift; /**< 各行避让位移的**目标值**(像素)。 */
std::vector<float> m_signalDragShiftCur; /**< 各行避让位移的**当前值**(逐帧插值)。 */
/**
* @brief 避让插值上一次推进的时刻(`GetTickCount64`,0 = 尚未推进过)。
*
* 插值系数按**真实帧间隔**算(`1 - exp(-dt/τ)`),因此不论是 60fps 定时器帧
* 还是鼠标快速拖动带来的高频帧,观感一致;固定系数在帧间隔抖动时会显得僵硬。
*/
std::uint64_t m_signalDragAnimLastMs = 0;
/**
* @brief 筛选模式下的分组折起状态(按分组下标)。
*
@@ -1485,6 +1592,21 @@ namespace jdi
*/
bool FilterMatchesSignal(const SignalData& signal) const;
/**
* @brief 取得「筛选状态下该分组中可见(通过筛选)的信号」掩码。
*
* 用户 2026-11-03 明确要求:筛选时组名前的复选框**只对应组内被筛选出来的信号**。
* 因此全选 / 部分选择 / 未选判定,以及点击复选框后的写入范围,
* 都以本函数返回的掩码为准(未通过筛选的信号不受影响)。
*
* 未处于筛选状态时返回**空**掩码(调用方应退回 `GroupAllVisible` 等全组语义)。
*
* @param [IN] groupIndex 分组下标。
* @param [OUT] mask 逐信号掩码(长度 = 该组信号数),true 表示通过筛选。
* @return 处于筛选状态返回 true。
*/
bool BuildFilterMask(int groupIndex, std::vector<bool>& mask) const;
bool m_pendingLaneMenu = false; /**< 左键抬起时是否弹出网格归属菜单。 */
SignalRef m_pendingLaneRef; /**< 待弹出菜单的目标信号。 */
bool m_pendingColorPicker = false; /**< 左键抬起时是否弹出拾色器。 */
@@ -262,6 +262,26 @@ namespace jdi
return WildcardMatchNoCase(signal.name, pattern);
}
bool SignalPanel::BuildFilterMask(int groupIndex, std::vector<bool>& mask) const
{
mask.clear();
if (m_filterText.empty() || m_dataSet == nullptr || groupIndex < 0)
{
return false; // 未筛选:调用方走"整组"语义
}
const SignalGroup* group = m_dataSet->GroupAt(groupIndex);
if (group == nullptr)
{
return false;
}
mask.reserve(group->signals.size());
for (const SignalData& signal : group->signals)
{
mask.push_back(FilterMatchesSignal(signal));
}
return true;
}
bool SignalPanel::IsGroupCollapsed(int groupIndex) const
{
if (groupIndex < 0)
@@ -736,6 +756,13 @@ namespace jdi
{
top -= m_rowShift[rowIndex];
}
// 信号条目拖拽排序的**避让位移**(用户 2026-11-03 要求):
// 本函数是行内全部派生的唯一几何来源,因此只需在这里叠加一次,
// 名称框 / 复选框 / 徽标 / 关闭 ✕ 等会自动跟随,形成"其它条目让位"的动画。
if (rowIndex < m_signalDragShiftCur.size())
{
top += m_signalDragShiftCur[rowIndex];
}
return gd::RectF(m_listRect.X + renderOffsetX, top + renderOffsetY, m_listRect.Width,
m_rows[rowIndex].height);
}
@@ -37,6 +37,12 @@ namespace jdi
HCURSOR SignalPanel::QueryCursor(int x, int y)
{
// 拖拽排序进行中:用"移动"光标提示当前处于拖动状态(此时不应再切成手型)。
if (m_signalDragging)
{
return LoadCursorW(nullptr, IDC_SIZEALL);
}
// 清空按钮 ✕ 是独立可点击控件,用手型光标给出除高亮之外的额外交互提示。
const gd::RectF clearRect = FilterClearRect();
if (clearRect.Width > 0.0f &&
@@ -99,6 +99,26 @@ namespace jdi
{
return ApplyWidthFromX(static_cast<float>(x));
}
// 信号条目拖拽:按下后越过阈值才真正进入拖拽态(之前只是"可能是点击")。
if (m_signalDragPending || m_signalDragging)
{
const int dx = x - m_signalDragStartX;
const int dy = y - m_signalDragStartY;
if (!m_signalDragging && (dx * dx + dy * dy) >= 16)
{
m_signalDragging = true;
m_signalDragShift.assign(m_rows.size(), 0.0f);
m_signalDragShiftCur.assign(m_rows.size(), 0.0f);
m_hoverRow = -1;
m_hoverPart = RowPart::NONE;
}
if (m_signalDragging)
{
UpdateSignalDrag(x, y);
return true;
}
return false; // 仍在阈值内:按普通移动继续处理(悬停高亮等)
}
if (m_draggingScrollBar)
{
const gd::RectF thumb = ScrollThumbRect();
@@ -484,9 +504,35 @@ namespace jdi
if (item.kind == RowKind::GROUP)
{
if (part == RowPart::CHECK)
{
// ⚠️ 筛选状态下组复选框**只作用于被筛选出来的信号**(用户 2026-11-03 明确要求):
// 全选时只勾上筛选结果,取消时也只取消筛选结果,
// 不会把未通过筛选的信号一起勾上/取消。
std::vector<bool> filterMask;
if (BuildFilterMask(item.groupIndex, filterMask))
{
const bool allVisible = m_tab->GroupAllVisibleSubset(item.groupIndex, filterMask);
if (allVisible)
{
// 取消:只取消筛选结果里的信号(其余保持原状)。
for (std::size_t i = 0; i < filterMask.size(); ++i)
{
if (filterMask[i])
{
m_tab->SetVisible(SignalRef{item.groupIndex, static_cast<int>(i)}, false);
}
}
}
else
{
m_tab->SetGroupVisibleSubset(item.groupIndex, filterMask);
}
}
else
{
const bool allVisible = m_tab->GroupAllVisible(item.groupIndex);
m_tab->SetGroupVisible(item.groupIndex, !allVisible);
}
m_rowsDirty = true;
return true;
}
@@ -548,6 +594,9 @@ namespace jdi
// 点击信号名(行主体):除了选中它,还要让绘图区的**目标网格**完整出现在可视区内
//(上百个独立网格时,旧有高亮根本无法定位)。悬停不触发这一步。
RequestPlotLaneScroll(ref);
// 同时登记"可能开始的拖拽排序":越过后阈值才真正进入拖拽态,
// 因此单击仍然是单击(用户 2026-11-03 要求按住可拖动排序)。
BeginSignalDrag(ref, row, x, y);
}
return true;
}
@@ -556,6 +605,15 @@ namespace jdi
{
bool changed = false;
// ---- 信号条目拖拽 ----
// ⚠️ 必须放在最前面:拖拽结束时列表条目位置可能变化,不能再走下方的 ✕ / 菜单判定。
if (m_signalDragging)
{
FinishSignalDrag();
return true;
}
m_signalDragPending = false;
// ---- 关闭类按钮(✕):按下时记录目标,抬起时若指针仍在同一个 ✕ 上才执行 ----
if (m_pendingFilterClear)
{
@@ -696,6 +754,10 @@ namespace jdi
return false;
}
// 双击进入改名:必须先把"可能开始的拖拽"作废,否则第二次按下会被当成新的拖拽起点,
// 改名后轻微移动就会拖着信号跑(用户 2026-11-03 要求拖拽排序,改名不能被它干扰)。
CancelSignalDrag();
const float fx = static_cast<float>(x);
const float fy = static_cast<float>(y);
@@ -932,9 +994,346 @@ namespace jdi
m_hoverStyleIndex = -1;
// 鼠标离开面板 → 取消绘图区的联动高亮(用户要求"移开鼠标时恢复")。
m_hoveredSignal = SignalRef();
// ⚠️ 拖拽中**不取消**:鼠标被拖到列表外(甚至面板外)时条目应继续跟随,
// 松手才提交(用户 2026-11-03 要求"拖拽时条目跟随鼠标")。
return changed;
}
// ======================================================================
// 信号条目拖拽排序(用户 2026-11-03 要求)
//
// 交互:左键按住信号行主体 → 越过 4px 阈值才进入拖拽(避免与单击选中/联动滚动抢事件);
// 拖拽中被拖条目**脱离原位置**跟随鼠标,其它信号行/分组行按插入位置做避让位移
//(目标值由 `UpdateSignalDragTargets` 给出,实际位移逐帧插值到 `m_signalDragShiftCur`);
// 松手时把(源分组、源下标、目标分组、目标下标)提交给宿主。
// ======================================================================
void SignalPanel::BeginSignalDrag(const SignalRef& ref, int row, int x, int y)
{
if (m_dataSet == nullptr || m_tab == nullptr || !ref.IsValid() || row < 0 ||
row >= static_cast<int>(m_rows.size()))
{
return;
}
m_signalDragPending = true;
m_signalDragging = false;
m_signalDragRef = ref;
m_signalDragRow = row;
m_signalDragStartX = x;
m_signalDragStartY = y;
m_signalDragMouseY = y;
m_signalDragDropGroup = -1;
m_signalDragDropIndex = -1;
m_signalDragDropRow = -1;
m_signalDragDropBefore = false;
m_signalDragShift.clear();
m_signalDragShiftCur.clear();
m_signalDragAnimLastMs = 0;
}
bool SignalPanel::UpdateSignalDrag(int x, int y)
{
(void)x;
if (!m_signalDragging)
{
return false;
}
m_signalDragMouseY = y;
// 拖到列表上下边缘时自动滚动:跨组移动往往需要越过很长的列表
//(上百个信号的 MF4 里拖到另一个分组)。
const float edge = kPadding * m_scale;
if (static_cast<float>(y) < m_listRect.Y + edge)
{
m_scrollOffset -= 6.0f * m_scale;
ClampScroll();
}
else if (static_cast<float>(y) > m_listRect.Y + m_listRect.Height - edge)
{
m_scrollOffset += 6.0f * m_scale;
ClampScroll();
}
UpdateSignalDragTargets();
// ⚠️ 鼠标移动时**也**推进一步插值:宿主定时器约 10ms 一帧,快速拖动时
// 避让动画会明显落后于鼠标(用户 2026-11-03 反馈“快速拖动略显僵硬”)。
// 在移动事件里补推进一次,让让位效果跟得上指针。
AdvanceSignalDragAnimation();
return true;
}
void SignalPanel::UpdateSignalDragTargets()
{
const std::size_t rowCount = m_rows.size();
m_signalDragDropGroup = -1;
m_signalDragDropIndex = -1;
m_signalDragDropRow = -1;
m_signalDragDropBefore = false;
m_signalDragShift.assign(rowCount, 0.0f);
if (m_signalDragShiftCur.size() != rowCount)
{
m_signalDragShiftCur.resize(rowCount, 0.0f);
}
if (m_dataSet == nullptr || !m_signalDragging || m_signalDragRow < 0 ||
m_signalDragRow >= static_cast<int>(rowCount))
{
return;
}
const float mouseY = static_cast<float>(m_signalDragMouseY);
// 鼠标所在的"自然行":`RowRect` 已叠加避让位移,这里减回去才代表用户看到的静止布局。
int hoverRow = -1;
bool below = false;
for (std::size_t index = 0; index < rowCount; ++index)
{
if (static_cast<int>(index) == m_signalDragRow)
{
continue;
}
const gd::RectF rect = RowRect(static_cast<int>(index));
const float top = rect.Y - m_signalDragShiftCur[index];
const float height = m_rows[index].height;
if (mouseY >= top && mouseY < top + height)
{
hoverRow = static_cast<int>(index);
below = (mouseY >= top + height * 0.5f);
break;
}
}
int dstRow = -1;
int dropGroup = -1;
int dropIndex = -1;
if (hoverRow < 0)
{
if (mouseY < m_listRect.Y && !m_rows.empty())
{
// 列表上方:落到第一个分组的最前面。
dstRow = 0;
dropGroup = m_rows.front().groupIndex;
dropIndex = 0;
}
else if (!m_rows.empty())
{
// 列表下方:落到最后一个含信号的分组的末尾。
for (int row = static_cast<int>(rowCount) - 1; row >= 0; --row)
{
const ListRow& item = m_rows[static_cast<std::size_t>(row)];
if (item.kind != RowKind::SIGNAL)
{
continue;
}
const SignalGroup* group = m_dataSet->GroupAt(item.groupIndex);
dstRow = static_cast<int>(rowCount);
dropGroup = item.groupIndex;
dropIndex = (group != nullptr) ? static_cast<int>(group->signals.size()) : 0;
break;
}
}
}
else
{
const ListRow& item = m_rows[static_cast<std::size_t>(hoverRow)];
if (item.kind == RowKind::GROUP)
{
// 组名行:插入到该组第一个信号之前(组名本身不让位)。
dstRow = hoverRow + 1;
dropGroup = item.groupIndex;
dropIndex = 0;
}
else
{
dstRow = below ? (hoverRow + 1) : hoverRow;
dropGroup = item.groupIndex;
dropIndex = item.signalIndex + (below ? 1 : 0);
}
}
if (dstRow < 0 || dropGroup < 0 || dropIndex < 0)
{
return;
}
// 同组时补回"源信号被摘出"造成的下标偏移(数据层先删除再插入)。
if (dropGroup == m_signalDragRef.groupIndex && dropIndex > m_signalDragRef.signalIndex)
{
--dropIndex;
}
m_signalDragDropGroup = dropGroup;
m_signalDragDropIndex = dropIndex;
m_signalDragDropRow = dstRow;
// 避让:被跨过的行整体让出 / 补上一个行高。
const int srcRow = m_signalDragRow;
const float laneHeight = m_rows[static_cast<std::size_t>(srcRow)].height;
if (dstRow > srcRow)
{
for (int row = srcRow + 1; row < dstRow && row < static_cast<int>(rowCount); ++row)
{
m_signalDragShift[static_cast<std::size_t>(row)] = -laneHeight;
}
}
else if (dstRow < srcRow)
{
for (int row = dstRow; row < srcRow; ++row)
{
m_signalDragShift[static_cast<std::size_t>(row)] = laneHeight;
}
}
}
void SignalPanel::CancelSignalDrag()
{
m_signalDragPending = false;
m_signalDragging = false;
m_signalDragRow = -1;
m_signalDragDropGroup = -1;
m_signalDragDropIndex = -1;
m_signalDragDropRow = -1;
m_signalDragDropBefore = false;
m_signalDragShift.clear();
m_signalDragShiftCur.clear();
m_signalDragAnimLastMs = 0;
}
void SignalPanel::FinishSignalDrag()
{
const SignalRef ref = m_signalDragRef;
const int dropGroup = m_signalDragDropGroup;
const int dropIndex = m_signalDragDropIndex;
const bool moved = m_signalDragging && dropGroup >= 0 && dropIndex >= 0 &&
(dropGroup != ref.groupIndex || dropIndex != ref.signalIndex);
CancelSignalDrag();
if (!moved || m_host == nullptr || !ref.IsValid())
{
return;
}
// wParam = (源分组 << 16) | 源信号下标;lParam = (目标下标 << 16) | 目标分组。
// 目标下标用 0xFFFF 表示"末尾"(与本消息的约定一致,见 Application 的消息处理)。
const LONG_PTR from = (static_cast<LONG_PTR>(ref.groupIndex) << 16) |
(static_cast<LONG_PTR>(ref.signalIndex) & 0xFFFF);
const LONG_PTR to = (static_cast<LONG_PTR>(dropIndex) << 16) |
(static_cast<LONG_PTR>(dropGroup) & 0xFFFF);
PostMessageW(m_host, WM_APP + 8, static_cast<WPARAM>(from), static_cast<LPARAM>(to));
}
float SignalPanel::SignalDragRowShiftFor(int row) const
{
if (row < 0 || static_cast<std::size_t>(row) >= m_signalDragShiftCur.size())
{
return 0.0f;
}
return m_signalDragShiftCur[static_cast<std::size_t>(row)];
}
bool SignalPanel::AdvanceSignalDragAnimation()
{
if (!m_signalDragPending && !m_signalDragging)
{
return false;
}
if (m_signalDragShiftCur.size() != m_signalDragShift.size())
{
m_signalDragShiftCur.resize(m_signalDragShift.size(), 0.0f);
}
// 按**真实帧间隔**求插值系数:`1 - exp(-dt/τ)`。
// ⚠️ 不能用固定系数:鼠标移动帧(可能 3~5ms)与定时器帧(约 10ms)混杂时,
// 固定系数会让动画忽快忽慢、拖动时显得僵硬(用户 2026-11-03 反馈)。
const std::uint64_t now = GetTickCount64();
float deltaMs = 16.0f;
if (m_signalDragAnimLastMs != 0 && now >= m_signalDragAnimLastMs)
{
deltaMs = static_cast<float>(now - m_signalDragAnimLastMs);
}
m_signalDragAnimLastMs = now;
deltaMs = std::max(1.0f, std::min(deltaMs, 60.0f));
// τ = 22ms:16ms 一帧时系数约 0.52(约 3 帧收敛),既跟手又有过渡。
const float factor = 1.0f - std::exp(-deltaMs / 22.0f);
for (std::size_t index = 0; index < m_signalDragShiftCur.size(); ++index)
{
const float target = m_signalDragShift[index];
float& current = m_signalDragShiftCur[index];
const float delta = target - current;
if (std::fabs(delta) < 0.4f)
{
current = target;
}
else
{
current += delta * factor;
}
}
return true;
}
void SignalPanel::PaintSignalDragGhost(gd::Graphics& g)
{
if (!m_signalDragging || m_signalDragRow < 0 ||
m_signalDragRow >= static_cast<int>(m_rows.size()) || m_theme == nullptr)
{
return;
}
const gd::RectF natural = RowRect(m_signalDragRow);
const float height = m_rows[static_cast<std::size_t>(m_signalDragRow)].height;
if (height <= 0.5f)
{
return;
}
// 被拖条目跟随鼠标(鼠标位于条目中部)。
float top = static_cast<float>(m_signalDragMouseY) - height * 0.5f;
top = std::max(m_listRect.Y, std::min(top, m_listRect.Y + m_listRect.Height - height));
const int state = g.Save();
g.SetClip(m_listRect, gd::CombineModeIntersect);
// ⚠️⚠️ **插入位置指示线必须最先画**(= 位于被拖条目之下):
// 用户 2026-11-03 明确要求“不要覆盖到正在拖拽的那个元素上,
// 始终处于跟随鼠标移动的绘制部分下方”。旧实现把它画在幽灵行之后,
// 条目恰好被拖到落点附近时,蓝线会横穿条目本体。
if (m_signalDragDropRow >= 0)
{
float lineY = 0.0f;
if (m_signalDragDropRow < static_cast<int>(m_rows.size()))
{
lineY = RowRect(m_signalDragDropRow).Y;
}
else if (!m_rows.empty())
{
const gd::RectF last = RowRect(static_cast<int>(m_rows.size()) - 1);
lineY = last.Y + last.Height;
}
if (lineY >= m_listRect.Y - 1.0f && lineY <= m_listRect.Y + m_listRect.Height + 1.0f)
{
UiDrawLine(g, m_listRect.X + 4.0f * m_scale, lineY,
m_listRect.X + m_listRect.Width - 4.0f * m_scale, lineY,
m_theme->accent, 2.0f * m_scale);
}
}
// 浮起的阴影:让条目明显"脱离"列表本体(画在指示线之上,遮住穿过的线)。
UiFillRoundRect(g, gd::RectF(natural.X + 2.0f * m_scale, top + 3.0f * m_scale,
natural.Width, height),
4.0f * m_scale, 0x33000000u);
// 复用行绘制:临时把行顶部覆盖到鼠标位置(与「组名悬浮」同一机制),
// 因此名称、复选框、GX 徽标、✕ 等全部随行一起移动,无需另写一套绘制。
m_rowYOverrideActive = true;
m_rowYOverrideY = top;
PaintSignalRow(g, m_signalDragRow);
m_rowYOverrideActive = false;
UiDrawRoundRect(g, gd::RectF(natural.X + 1.0f * m_scale, top + 1.0f * m_scale,
natural.Width - 2.0f * m_scale, height - 2.0f * m_scale),
4.0f * m_scale, m_theme->accent, 1.6f * m_scale);
g.Restore(state);
}
} // namespace jdi
+80 -4
View File
@@ -211,8 +211,9 @@ namespace jdi
// ⚠️ 信号右键菜单**刻意保持极简**(用户 2026-10-29 要求):
// 颜色、绘制样式、可见性、显示全部/隐藏全部这些原来放在这里的条目,现在
// 都已被下方**样式自定义区**与分组右键菜单覆盖,重复的入口只会让菜单变长、变乱。
// 现在只保留两件事:
// * 「克隆该信号」(在「仅显示该信号」**之上**);
// 现在只保留三件事:
// * 「克隆该信号」(在「克隆该信号(带调整)」**之上**):原始数据 + 只沿用时间轴偏移;
// * 「克隆该信号(带调整)」:原始数据 + 沿用源信号的**全部**调整参数(用户 2026-11-03 要求);
// * 「仅显示该信号」。
std::vector<MenuItem> items;
@@ -226,6 +227,46 @@ namespace jdi
clone.id = CMD_SIGNAL_CLONE;
items.push_back(clone);
MenuItem cloneAdjusted;
cloneAdjusted.label = L"克隆该信号(带调整)";
cloneAdjusted.id = CMD_SIGNAL_CLONE_ADJUSTED;
items.push_back(cloneAdjusted);
// 「移动到…」二级菜单(用户 2026-11-03 要求,位置在「仅显示该信号」**之上**):
// 条目为「组号 | 组名」+ 当前所在分组打勾;末尾分割线之下是「新建分组」。
// 结构与信号名右侧 GX 徽标点击后的「所属网格」菜单保持一致,但**没有标题行**,
// 且底部动作换成「新建分组」(新建分组后把信号移进去)。
MenuItem moveMenu;
moveMenu.kind = MenuItemKind::SUBMENU;
moveMenu.label = L"移动到...";
moveMenu.id = CMD_GROUP_MOVE_BASE; // 父项本身不可点击,id 仅用于区分
for (std::size_t index = 0; index < m_dataSet->GroupCount(); ++index)
{
const SignalGroup* group = m_dataSet->GroupAt(static_cast<int>(index));
if (group == nullptr)
{
continue;
}
MenuItem entry;
// ⚠️ 显示形式为「组号 + 竖线 + 组名」(用户 2026-11-03 明确要求),
// **不要**用括号把组名括起来(旧实现 `分组 1(名称)` 已被要求改掉)。
entry.label = std::to_wstring(index + 1) + L" | " + group->name;
entry.id = CMD_GROUP_MOVE_BASE + static_cast<int>(index);
entry.checked = (static_cast<int>(index) == ref.groupIndex);
moveMenu.submenu.push_back(entry);
}
MenuItem moveSeparator;
moveSeparator.kind = MenuItemKind::SEPARATOR;
moveMenu.submenu.push_back(moveSeparator);
MenuItem moveNewGroup;
moveNewGroup.label = L"新建分组";
moveNewGroup.id = CMD_GROUP_MOVE_NEW;
moveMenu.submenu.push_back(moveNewGroup);
// 分组很多(例如一次加载上百个通道组)时二级菜单同样要能滚动:
// 与「所属网格」菜单同一上限(用户 2026-10-16 要求保留 10 行)。
moveMenu.submenuMaxRows = kLaneMenuVisibleRows;
items.push_back(moveMenu);
MenuItem solo;
solo.label = L"仅显示该信号";
solo.id = CMD_SIGNAL_SOLO;
@@ -527,7 +568,7 @@ namespace jdi
switch (result.id)
{
case CMD_SIGNAL_CLONE:
// 「克隆该信号」:把原信号的「调整」(除时间轴偏移)烘焙进采样值后插入下方,
// 「克隆该信号」:**不烘值**,原始数据 + 沿用源信号的时间轴偏移(用户 2026-11-03)。
// 具体构建在宿主里完成(需要访问 DataSet 与全部标签页)。
if (m_menuTargetSignal.IsValid() && m_host != nullptr)
{
@@ -536,6 +577,15 @@ namespace jdi
static_cast<LPARAM>(m_menuTargetSignal.signalIndex));
}
break;
case CMD_SIGNAL_CLONE_ADJUSTED:
// 「克隆该信号(带调整)」:**不烘值**,原始数据 + 沿用源信号的**全部**调整参数。
if (m_menuTargetSignal.IsValid() && m_host != nullptr)
{
PostMessageW(m_host, WM_APP + 6,
static_cast<WPARAM>(m_menuTargetSignal.groupIndex),
static_cast<LPARAM>(m_menuTargetSignal.signalIndex));
}
break;
case CMD_SIGNAL_SOLO:
if (m_menuTargetSignal.IsValid())
{
@@ -574,8 +624,34 @@ namespace jdi
needRelayout = true;
}
break;
case CMD_GROUP_MOVE_NEW:
// 「移动到… → 新建分组」:由宿主新建分组并把信号移过去(需要访问全部标签页)。
if (m_menuTargetSignal.IsValid() && m_host != nullptr)
{
PostMessageW(m_host, WM_APP + 7,
static_cast<WPARAM>(m_menuTargetSignal.groupIndex),
static_cast<LPARAM>(((static_cast<LONG_PTR>(m_menuTargetSignal.signalIndex) << 16) |
0xFFFF)));
}
break;
default:
if (result.id >= CMD_LANE_BASE && result.id < CMD_LANE_NEW)
if (result.id >= CMD_GROUP_MOVE_BASE && result.id < CMD_GROUP_MOVE_NEW)
{
// 「移动到… → 分组 N」:同样交给宿主(跨分组移动会重建时间轴归属)。
if (m_menuTargetSignal.IsValid() && m_host != nullptr)
{
const int targetGroup = result.id - CMD_GROUP_MOVE_BASE;
if (targetGroup != m_menuTargetSignal.groupIndex)
{
PostMessageW(m_host, WM_APP + 7,
static_cast<WPARAM>(m_menuTargetSignal.groupIndex),
static_cast<LPARAM>(
(static_cast<LONG_PTR>(m_menuTargetSignal.signalIndex) << 16) |
(static_cast<LONG_PTR>(targetGroup) & 0xFFFF)));
}
}
}
else if (result.id >= CMD_LANE_BASE && result.id < CMD_LANE_NEW)
{
if (m_menuTargetSignal.IsValid())
{
@@ -179,13 +179,20 @@ namespace jdi
const int rowCount = static_cast<int>(m_rows.size());
// 先定出要「悬浮」的分组行(若有):常规循环跳过它,最后统一钉在列表顶部绘制,
// 否则它会先被画一次在滚动位置上、又被画一次在顶部(视觉上重叠两份)。
const int stickyRow = StickyGroupRowIndex(rowCount);
// ⚠️ 拖拽排序期间**不钉**:被拖条目会随鼠标上下移动,钉住组名会让"让位"
// 的观感错乱(用户 2026-11-03 要求拖拽时其它条目做避让动画)。
const int stickyRow = m_signalDragging ? -1 : StickyGroupRowIndex(rowCount);
for (int row = 0; row < rowCount; ++row)
{
if (row == stickyRow)
{
continue;
}
// 被拖拽的条目不再画在原位置:它由 `PaintSignalDragGhost` 画在鼠标处。
if (m_signalDragging && row == m_signalDragRow)
{
continue;
}
const gd::RectF rect = RowRect(row);
if (rect.Y + rect.Height < m_listRect.Y || rect.Y > m_listRect.Y + m_listRect.Height)
{
@@ -220,6 +227,8 @@ namespace jdi
}
PaintStickyGroupRow(g, stickyRow);
// 被拖拽的信号条目:跟随鼠标、脱离列表本体绘制(并在落点画插入指示线)。
PaintSignalDragGhost(g);
}
g.Restore(saveState);
@@ -340,10 +349,22 @@ namespace jdi
mix(IsGroupCollapsed(item.groupIndex) ? 1ull : 0ull);
mix(static_cast<std::uint64_t>(item.displayCount + 1));
if (m_tab != nullptr)
{
// ⚠️ 必须与绘制侧同源:筛选状态下复选框反映的是**筛选子集**的三态,
// 若仍用整组的 `GroupAllVisible` 入键,勾选筛选结果后缓存键不变
// ⇒ 复选框不会重画(用户 2026-11-03 报告的问题之一)。
std::vector<bool> filterMask;
if (BuildFilterMask(item.groupIndex, filterMask))
{
mix(m_tab->GroupAllVisibleSubset(item.groupIndex, filterMask) ? 1ull : 0ull);
mix(m_tab->GroupHasVisibleSubset(item.groupIndex, filterMask) ? 2ull : 0ull);
}
else
{
mix(m_tab->GroupAllVisible(item.groupIndex) ? 1ull : 0ull);
mix(m_tab->GroupHasVisible(item.groupIndex) ? 2ull : 0ull);
}
}
const bool editing = (m_editField == StyleEditField::GROUP_TITLE && m_editRow == row);
mix(editing ? 1ull : 0ull);
if (editing)
@@ -582,8 +603,21 @@ namespace jdi
// 分组复选框(三态:全选 / 部分 / 未选)。
const gd::RectF checkRect(baseX + kDisclosureWidth * m_scale, centerY - kCheckSize * m_scale * 0.5f,
kCheckSize * m_scale, kCheckSize * m_scale);
const bool allVisible = m_tab->GroupAllVisible(item.groupIndex);
const bool anyVisible = m_tab->GroupHasVisible(item.groupIndex);
// ⚠️ 筛选状态下复选框**只反映组内被筛选出来的信号**(用户 2026-11-03 明确要求):
// 「不论是否筛选,这个复选框影响的都只有当前组名下显示的元素」。
std::vector<bool> filterMask;
bool allVisible = false;
bool anyVisible = false;
if (BuildFilterMask(item.groupIndex, filterMask))
{
allVisible = m_tab->GroupAllVisibleSubset(item.groupIndex, filterMask);
anyVisible = m_tab->GroupHasVisibleSubset(item.groupIndex, filterMask);
}
else
{
allVisible = m_tab->GroupAllVisible(item.groupIndex);
anyVisible = m_tab->GroupHasVisible(item.groupIndex);
}
UiDrawCheckBox(g, checkRect, allVisible ? 1 : (anyVisible ? 2 : 0), 3.0f * m_scale);
const gd::RectF closeRect = GroupCloseRect(row);
+505
View File
@@ -9658,4 +9658,509 @@ E 面板:四行几何存在;最大间隔行文本(含(频率)尾缀)
`make release` / `make debug` 零 warning;产物 **856,064 B**(< 1MB 红线)。
---
## 2026-11-03 第二十六轮(本机,`dev` 分支):重采样面板拖不动/关不掉 + 统计与输入框频率不一致 + 派生烘焙接入公式 + 克隆拆分
> 用户报告四件事:①「重采样」面板拖不动、✕ 关不掉(只能改右键菜单);② 面板统计的「最小数据间隔」
> 与「重采样频率」输入框显示值不一致(`MF4-Record.MF4` 的 `m_MOT_Current_DCom[0]`:统计 3ms 700us(270.27Hz)
> 而输入框 270.517Hz);③ 所有派生烘焙都没有接入「公式映射」;④ 信号右键菜单要拆成「克隆该信号」
> 与「克隆该信号(带调整)」。
> ⚠️ **本轮我先擅自加了两行 UI 统计行,被用户明确否决并已全部回退**(见下面「教训」)。
### 1. 「重采样」面板拖不动 + ✕ 关不掉(两个独立缺陷)
* **拖不动**:`OnLButtonDown` 里 `resampleMode` 分支处理完条目/输入框/按钮后**无条件 `return true`**,
而「标题栏 → `EDGE_PANEL_MOVE`」的判定在面板分支**最末尾** ⇒ 标题栏按下被吞掉、`m_dragMode` 恒为 NONE。
⇒ 改为**只消费 `insideContent` 之内的点击**,标题栏留给后面。
(同款缺陷在 `OnMouseMove` 的 `EDGE_PANEL_MOVE` → `switch (m_edgePanelDragKind)` 里也有一份:
**漏了 `case EdgePanelKind::RESAMPLE`** ⇒ 位置写进 `edgePanel`、又从 `resamplePanel` 读 ⇒ 纹丝不动。)
* **✕ 关不掉**:`OnLButtonUp` 的 `m_pendingPanelClose` 分支 `switch (kind)` **漏了 `RESAMPLE`**
⇒ 落进 `default: SetLaneEdgeCount(...)`(关掉的是「边沿计数」)。补上 `SetLaneResample(lane, false)`。
* ⚠️ **规律**:新增实验性功能面板时,`EdgePanelKind` 的每一处 `switch` **都必须逐个补分支**。
历史踩坑:`MARK` 漏过一次(拖不动)、`RESAMPLE` 这次漏了两处(拖不动 + 关不掉)。
**建议以 `kEdgePanelKinds[]` 为准逐个 grep 每个 `switch (kind)`**。
### 2. ⭐⭐⭐ 统计「最小数据间隔」与输入框频率不一致 —— 真因与准数
实测(真实 `MF4-Record.MF4` / `m_MOT_Current_DCom[0]`,8475 采样,6857 个不同间隔):
| 量 | 原始值 | 统计行显示 | 输入框显示 |
| --- | --- | --- | --- |
| 最小间隔 | **0.003696631 s** | `3ms 700us` | `3ms 700us` |
| 对应频率 | **270.516586589 Hz** | `(270.27 Hz)` | `270.517 Hz` |
| 最大间隔 | 0.016182614 s | `16ms 200us(61.7284 Hz)` | — |
**真因**:两条路径用了**不同的换算基准**。
* 统计行:`intervalWithFrequency()` = 先 `RoundIntervalForDisplay()`(3 位有效数字 → `3.70ms`)
**再**用舍入后的值换算频率 ⇒ `1/0.0037 = 270.27 Hz`。
* 输入框:`FormatFrequencyText(1.0 / interval)` 用**未舍入**的原始 `0.003696631` ⇒ `270.517 Hz`。
* ⇒ **两者都不是"错的数",但统计行的频率是"由显示用近似值算出来的",是错的**。
**准确的频率 = 270.517 Hz**(真值 270.5166)。
**修法(唯一改法,不动 UI)**:频率**必须由与间隔输入框同一份显示值换算** ——
`PlotViewPaint.cpp` 的频率输入框改为
`shownInterval = RoundIntervalForDisplay(interval); hertz = 1.0 / shownInterval;`
⇒ 输入框显示 `270.27 Hz` 与统计行完全一致,且**间隔文本也一致**(`3ms 700us`)。
⚠️ **本来两者是对应的**:`interval` 的默认值 = 源信号**最小数据间隔**(`SetLaneResample` /
`ToggleLaneResampleSignal` 里 `item.interval = minInterval`),所以"输入框默认值 == 统计最小间隔"
这条语义本来就是对的,坏的只有频率的换算基准。
⚠️ **不要**为此新增任何统计行/改面板行数(用户只要求修数值一致性)。
### 3. ⭐⭐ 派生烘焙没有接入「公式映射」
`BuildDerivedSignal` 的烘值判定与 `CloneSignal` 各写了一份
`gain != 1 || absolute || mirrorVertical || offsetY != 0`,**漏掉了 `HasFormula()`**
⇒ 带公式的信号派生/克隆出来的新信号**既没有烘焙公式、也没有继承公式** ⇒ 曲线与预览不一致。
**修法**:判定统一写成含 `sourceTransform.HasFormula()` 的五项
(`公式 → 系数 → 绝对值 → 垂直镜像 → Y 偏移`,与 `ApplyDrawTransform` 的结算顺序同源)。
`BuildResampledSignal` 本来就是 `ApplyDrawTransform` 全程烘值,天然包含公式,只需补注释。
### 4. 信号右键菜单:克隆拆成两个语义
| 菜单项 | 采样值 | 样式 |
| --- | --- | --- |
| 「克隆该信号」 | **原始数据,不烘焙** | 外观 + **只保留时间轴偏移**;公式/系数/绝对值/镜像/Y 偏移全部复位 |
| 「克隆该信号(带调整)」 | **原始数据,不烘焙** | 外观 + **整套调整**(公式/系数/Y 偏移/时间轴偏移/水平与垂直镜像/绝对值) |
* 「克隆该信号」**不再烘焙任何变换**(旧实现会把调整烘进采样值),用户 2026-11-03 明确要求"均不直接烘焙"。
* 新增 `Application::CloneSignalAdjusted` + `CMD_SIGNAL_CLONE_ADJUSTED = 6` +
`kMessageCloneSignalAdjusted = WM_APP + 6`(参数约定同为 `wParam=group, lParam=signal`)。
* 两者都**保留时间轴偏移**(共用同一条文件时间轴,烘进采样值会在越界段写 NaN,
见 `BuildDerivedSignal` 2c 的历史说明)。
### 5. ⚠️⚠️ 教训:离屏探针崩溃会弹**模态「内存不足」框**,反复运行 = 反复弹窗
* `Utilities/Src/MemProbe.cpp` 的 `OnTerminate()` 会 `MessageBoxW("内存不足…")` 然后 `_Exit`。
本项目 `-fno-exceptions`(`NO_EXCEPTIONS=1`),**任何**未预检的分配失败 / 断言崩溃都会走到它。
* 本轮我先写的探针 `p28` 在 `Application::Initialize` + 首次 `Paint` 路径上崩了 3~4 次,
每次都弹一次模态框 —— 用户看到的就是"十几次内存不足报警"。
**真实进程并没有内存问题**(`MF4-Record.MF4` 只有 8475 采样,几十 MB)。
* **对策(务必遵守)**:
1. 探针**不要**为了"跑起来"去 `Initialize` + `Render`;纯数据层/函数级验证直接构造
`DataSet`/`SignalTab`,或用 `Application` 但**不调 `Initialize()`**(本轮 `endtoend.cpp` 就是如此,12/12 全绿)。
2. 探针必须 `setvbuf(stdout, nullptr, _IONBF, 0)` 并输出 `[start]/[initialized]/[painting]` 之类标记,
崩溃后才能定位到具体阶段(本轮正是靠它确认崩在首帧绘制)。
3. **绝不用"反复重跑"来试探**:一次崩溃就先读标记定位,再改探针,否则每次重跑都可能弹一个模态框。
4. 探针里 `PlotView::OnLButtonDown` 的坐标必须来自 `EdgePanelLayoutFor()` 的真实几何,
且先跑一帧让布局成型(未初始化时 layout 全零 ⇒ 所有命中断言假失败)。
### 6. 本轮验证(全部离屏、无窗口、不弹任何对话框)
* `.preflight/r26/r26.exe`(真实 `MF4-Record.MF4`):打印原始间隔、`SignalDataInterval`、
修复前后的两条路径文本 ⇒ 修复后 `[consistency] ... SAME`,输入框与统计行同为 `270.27 Hz`。
* `.preflight/r26/logic.exe`:公式判定 / 克隆样式规则 **7/7**。
* `.preflight/r26/endtoend.exe`(不 `Initialize`):派生烘焙含公式(`worst=0`、`delta=4`)、
克隆不烘值、带调整克隆复制整套参数 ⇒ **12/12**。
* `make release TARGET_FILE_NAME=TargetCheck` 零 warning。
### 7. ⚠️ 流程教训(用户批评:Token 花在探针上、真问题没定位)
* 本轮真正的定位只用了**一个 40 行的数据探针**(打印 6 个数字)就锁定了根因;
之前那轮写了 300 行 UI 探针反而绕远。
* **先打印数据、再改 UI**;`FormatIntervalText` / `FormatFrequencyText` / `RoundIntervalForDisplay`
这类"显示层"函数的**舍入基准**是首要怀疑对象。
* **不要擅自改用户的 UI 布局/行数**——发现问题优先问,或只改数值来源。
---
## 2026-11-03 第二十六轮(本机 dev):重采样面板拖动/关闭失效 + 显示精度按真实值 + 派生烘焙接入公式 + 克隆拆分
> 用户报告的六件事:①「重采样」面板拖不动、✕ 关不掉;②统计「最小数据间隔」与「重采样频率」
> 输入框显示值不一致;③所有派生烘焙没接入「公式映射」;④信号右键菜单要拆成两个克隆;
> ⑤显示精度:间隔要 3ms 696.631us、频率要 270.517 Hz(真实值保留三位小数),
> 而不是 3ms 700us / 270.27 Hz;⑥面板仍须四行,不得擅自增改 UI。
### 1. 「重采样」面板拖不动 + ✕ 关不掉(两个独立缺陷)
- 拖不动(两处):
- `OnLButtonDown` 的 `resampleMode` 分支末尾无条件 `return true`,把标题栏按下也吞了,
而「标题栏 -> `EDGE_PANEL_MOVE`」的判定在面板分支最末尾 => 改为只消费 `insideContent` 内的点击。
- `OnMouseMove` 的 `EDGE_PANEL_MOVE` -> `switch (m_edgePanelDragKind)` 漏 `case RESAMPLE`
=> 位置写进 `edgePanel`、又从 `resamplePanel` 读 => 纹丝不动。
- ✕ 关不掉:`OnLButtonUp` 的 `m_pendingPanelClose` -> `switch (kind)` 漏 `RESAMPLE`
=> 落进 `default: SetLaneEdgeCount(...)`(关掉的是「边沿计数」)。补 `SetLaneResample(lane, false)`。
- 规律:新增 `EdgePanelKind` 时每个 `switch (kind)` 都要补分支。
历史踩坑:`MARK` 漏过一次、`RESAMPLE` 这次漏了两处。以 `kEdgePanelKinds[]` 为准逐个 grep。
### 2. 显示精度:一律用真实值,次级单位/频率保留三位小数
真实现测(MF4-Record.MF4 / m_MOT_Current_DCom[0],8475 采样,6857 个不同间隔):
| 量 | 真实值 | 旧显示(错) | 现行显示(对) |
| --- | --- | --- | --- |
| 最小间隔 | 0.003696631 s | 3ms 700us | **3ms 696.631us** |
| 最小间隔频率 | 270.516586589 Hz | 270.27 Hz | **270.517 Hz** |
| 最大间隔 | 0.016182614 s | 16ms 200us | **16ms 182.614us** |
| 最大间隔频率 | 61.7947 Hz | 61.7284 Hz | **61.795 Hz** |
两个根因:
1. `RoundIntervalForDisplay()` 把间隔舍入到 3 位有效数字(0.003696631 -> 0.0037)
=> 次级单位变成 700us,尾数被抹掉。该函数已删除。
2. 频率用舍入后的 3.70ms 换算 => 270.27 Hz;而输入框用原值 => 270.517 Hz
=> 两处不一致。现在统一用真实间隔换算,天然一致。
修法(`Utilities/Src/Utf8Util.cpp`):
- 新增 `FormatDecimalFixed(value, decimals = 3)`:固定小数位、保留尾随 0、
绝不出现科学计数法(与 `FormatDecimal` 的去尾随 0 语义相反)。
- 新增 `FormatSubUnit(value, maxDecimals)`:带小数才补到 3 位,整数不加小数点
(5.06s -> 5s 60ms,而不是 5s 60.000ms)。
- `FormatDurationCompound` 的两处次级单位(s+ms、ms/us/ns)改用它。
- `FormatFrequency` 改为 `FormatDecimalFixed(scaled, 3)`;若保留 3 位后变成 0.000
(超低频,如 1/3662s ≈ 0.27mHz)则退回 `FormatDecimal`,避免把有意义的值显示成 0。
- `PlotViewInternal.hpp`:删除 `RoundIntervalForDisplay`,`FormatIntervalText` 直接用真实值。
- `PlotViewPaint.cpp`:统计行 `intervalWithFrequency` 与频率输入框都用真实 `interval`。
关键不变式:任何显示路径都不得先舍入再换算(舍入只能发生在最后一步输出文本时,
且间隔与频率必须基于同一个未舍入的原始值)。这正是旧实现两次翻车的同一个坑。
### 3. 派生烘焙接入「公式映射」
`BuildDerivedSignal` / `CloneSignal` 的烘值判定漏了 `HasFormula()`
=> 带公式的信号派生/克隆后公式被静默丢弃。统一为
`HasFormula() || gain != 1 || absolute || mirrorVertical || offsetY != 0`
(与 `ApplyDrawTransform` 的结算顺序同源)。`BuildResampledSignal` 本就全程走 `ApplyDrawTransform`。
### 4. 信号右键菜单:克隆拆成两个语义(均不烘焙采样值)
| 菜单项 | 采样值 | 样式 |
| --- | --- | --- |
| 「克隆该信号」 | 原始数据 | 外观 + 仅时间轴偏移,其余调整复位 |
| 「克隆该信号(带调整)」 | 原始数据 | 外观 + 整套调整(公式/系数/Y 偏移/时间轴偏移/水平与垂直镜像/绝对值) |
新增 `Application::CloneSignalAdjusted`、`CMD_SIGNAL_CLONE_ADJUSTED = 6`、
`kMessageCloneSignalAdjusted = WM_APP + 6`。两者都保留时间轴偏移
(共用同一条文件时间轴,烘进采样值会在越界段写 NaN)。
### 5. 流程教训(用户两次严厉批评,必须遵守)
1. 不要擅自改 UI:用户只要求修数值,却新增了两行统计行 —— 已被明确否决并回退。
面板保持四行(最大间隔 / 最小间隔 / 重采样间隔 / 重采样频率)。
2. 不要无脑跑探针:大段 `Initialize()` + `Render()` 的探针既慢又容易崩,
崩了会经 `MemProbe::OnTerminate()` 弹模态「内存不足」框
(`-fno-exceptions` 下任何分配失败/断言都会走它),用户会以为程序真没内存。
=> 优先写只链接 `Utf8Util.o` 的纯值打印程序(本轮只用了 60 行、秒级验证、零弹框风险)。
3. 先打印数值、再改代码:本轮定位只用了"打印 6 个数字",
比前一轮 300 行 UI 探针快得多也准得多。
4. `.preflight` 已被用户删除,不要再重建测试框架目录。
5. 写中文记忆文件必须走 Python 或编辑工具,不能经 PowerShell 的 `Get-Content`/`WriteAllText`
往返(会按 GBK 解码再编码,中文全毁)。
### 6. 本轮验证(纯值打印,无窗口)
`make release TARGET_FILE_NAME=TargetCheck` 零 warning。格式化输出实测:
```
min interval 0.003696631s -> 3ms 696.631us
frequency 1/0.003696631 -> 270.517 Hz
max interval 0.016182614s -> 16ms 182.614us
frequency 1/0.016182614 -> 61.795 Hz
5.06s -> 5s 60ms (整数次级单位不加小数点)
1h 1min 2s freq -> 0.000273075 Hz (超低频不显示成 0)
5.123ps -> 5.123 ps
```
---
## 2026-11-03 第二十七轮(本机 dev):重采样统计不被省略 + 筛选态组复选框只作用于筛选结果 + 新增「选区截取」
### 1. 重采样面板统计信息不再被省略成 `…`
根因有两个,**同时**存在才导致截断:
1. `EdgePanelWidth` 里数值列按**典型样例**(`1h 1min 2s(1.23456 GHz)`)估算宽度,
而真实值(`16ms 182.614us(61.795 Hz)`)更长 ⇒ 绘制矩形不够宽。
2. 文本超出矩形后被裁掉,表现出来就是尾部看不到。
修法:
* 新增 `FormatIntervalWithFrequency(seconds)`(`PlotViewInternal.hpp`)作为
`间隔(频率)` 文本的**唯一实现**,宽度计算与绘制都调它。
* `EdgePanelWidth` 改为**逐信号实测** `FormatIntervalWithFrequency(maxInterval/minInterval)`
的真实宽度,取最大值(并保留一个典型值作为无勾选信号时的下限)。
* 面板宽度仍受 `min(width, 绘图区宽 - 8px)` 约束,但宽度基线已按真实文本撑开。
⚠️ 规则:**面板宽度计算与绘制必须共用同一个"生成显示文本"的函数**。
用样例文本估宽是这次截断的直接原因。
### 2. 筛选状态下组复选框只作用于「筛选出来的信号」
用户要求:*不论是否筛选,这个复选框影响的都只有当前组名下显示的元素*。
* `TabConfig` 新增 `SetGroupVisibleSubset(groupIndex, mask)` /
`GroupAllVisibleSubset(...)` / `GroupHasVisibleSubset(...)`:
只读写 `mask` 为 true 的元素(= 通过筛选的信号),其余信号**可见性不变**。
* `SignalPanel::BuildFilterMask(groupIndex, mask)`:未筛选时返回 false(调用方走整组语义),
筛选时返回逐信号掩码。
* `SignalPanelInteraction` 的组复选框:
- 筛选态:`GroupAllVisibleSubset` 判定三态;全选时 `SetGroupVisibleSubset`(只勾筛选结果),
取消时逐个 `SetVisible(false)`(也只动筛选结果);
- 非筛选态:保持原 `SetGroupVisible`。
* `SignalPanelPaint` 的复选框绘制与**行位图缓存键**都改用同一套子集判定
(只改绘制不改缓存键 ⇒ 勾选后复选框不刷新)。
* 效果:筛选结果全勾 ⇒ 该组复选框显示已勾选;被筛掉的信号不受影响。
### 3. 新增实验性功能「选区截取」(`EdgePanelKind::CLIP = 6`)
位置:「实验性功能」二级菜单里,位于「重采样」之下、「自由标记」**上方**,
带 `√` 的切换项(`PLOT_CMD_LANE_CLIP = 14`)。
**面板**(`Lane::clip` / `clipItems` / `clipPanel` / `clipScroll`,沿用可滚动列表布局):
* 逐信号一行:**信号色叠底条目栏 + 右侧复选框**,**不可展开**(用户明确要求);
* 信号过多时内容区滚动(滚轮 / 拖动滑块);
* 底部**两个不参与滚动**的按钮:「截取为新信号」与「原始信号截取」;
* ⚠️ **未启用双光标**(`CursorMode != DOUBLE`)时:两个按钮为**空矩形**(不可点),
面板内居中显示提示「请先启用「双光标」模式」。
**两个按钮的语义**:
| 按钮 | 采样值 | 样式 | 内存 |
| --- | --- | --- | --- |
| 截取为新信号 | 区间内**原始数据,不烘焙** | **继承源信号整套调整** | 新轴 + 新信号 |
| 原始信号截取 | 保留区间内,**区间外丢弃** | 不变(就地改写源信号) | `shrink_to_fit` 归还 |
* 新信号名 = `<源信号名> (AAAA - BBBB)`,AAAA/BBBB 是选区两端**显示时刻**,
`FormatDecimalFixed(..., 3)` + `s`(如 `m_MOT_Current_DCom[0] (1.234s - 2.345s)`)。
* 插入到源信号下方(`InsertSignalBelowSource`),沿用它新增的独立时间轴
(`DataSet::AppendGroupAxis`)。
* 破坏性截取走 `DataSet::ClipSignalRange(groupIndex, signalIndex, low, high)`:
同时裁剪 `axes[signal.axisIndex].time` 与 `values`(按下标对齐),
用「新建 + swap + `shrink_to_fit`」真正归还内存,随后重算
`ComputeStats`(min/max/finiteCount/粗粒度包络)与轴的 `minTime/maxTime/monotonic`。
⚠️ 调用前先 `PauseDerivedAsync()`,避免后台派生读到已释放的采样。
**⚠️⚠️ 时间轴偏移必须参与区间比较**:
双光标给出的是**显示时刻**(`time + offsetTime`),而采样时刻是 `time`。
`BuildClippedSignal` 逐点比较用 `time + offsetTime`;
`ClipSignalInPlace` 把区间换算回采样时刻 `low - offsetTime` / `high - offsetTime`。
(不换算 ⇒ 带时间轴偏移的信号会截错区间。)
**同步点(漏一处就会出现"拆网格后条目指错信号")**:
`LaneCarry`(三处:结构体字段 / 采集 / 回填)、`SplitLane`(状态 + 条目过滤)、
`PruneLaneSignalItems`、`RemapAfterSignalInsert`、`PurgeSignalFromLanes` 的 `purgeRefs`、
`PanelSlotKey`(按钮矩形)、`CollectDebugBoxes`、`SceneContentHash`、
`kEdgePanelKinds[]`、`kOrder[]`(错开序数)、`EdgePanelLayoutFor` 的 `enabled/open/collapsed/storedX/storedY`、
`EDGE_PANEL_MOVE` 的 `switch (m_edgePanelDragKind)`、`m_pendingPanelClose` 的 `switch (kind)`、
MEAN_SCROLL / 滚轮 / 悬停 / `SetLanePanelCollapsed` / `LanePanelCollapsed` / `QueryCursor`。
### 4. 教训(延续上一轮)
* 新增 `EdgePanelKind` 时**每个 `switch (kind)` 都要补分支**(本轮 CLIP 一次补齐了全部);
用 `grep "EdgePanelKind::RESAMPLE"` 反查所有需要同步的位置最省事。
* 多层三元表达式(`enabled/open/collapsed/storedX`)手工加一层极易多/少一个 `)`,
本轮因此连续 3 次编译失败。**改这类表达式时先用 Python 数列数括号**,或改成查表。
* `make clean` 在用户正开着 `Target.exe` 时会失败(文件被占用),
改用"删 `Build/Release/*.o` + `make`"做全量重编译验证即可。
---
## 2026-11-03 第二十八轮(本机 dev):选区截取面板五处缺陷修复
> 用户实测报告的五个问题:①未启用双光标时仍显示信号条目、过多信号还能滚动;
> ②条目滚动区与底部按钮之间有一大段空白(未启用双光标时也有);
> ③多个信号同时「原始信号截取」结果不对;④有滚动条时滚轮滚不到底。
### 1. 未启用双光标时不应显示信号条目
**根因**:`EdgePanelLayoutFor` 与绘制侧都只判断 `clipPanel`,没有判断「双光标是否启用」,
于是照常按信号数累加内容高度、生成行矩形、绘制条目。
**修法**(`PlotViewPanels.cpp`):
* 高度算式新增 `clipReady = clipPanel && CursorMode()==DOUBLE`:
未就绪时 `content = 一行高度`(只放提示),不遍历信号。
* 行几何循环加 `clipRowsVisible = !clipPanel || clipReadyHere`,未就绪时**不生成任何行矩形**
⇒ 绘制侧(依赖 `layout.rowRefs`)自然什么都不画。
* footer 也改为「未就绪 = 0」:`clipPanel ? (clipReady ? 两行按钮 : 0) : ...`。
### 2. ⭐ 条目区与按钮之间的大段空白
**两个叠加根因**:
1. 高度算式里 `maxContent = max(3*groupRow, min(上限, max(3*groupRow, budget)))`
—— 那个「至少 3 行」的下限让只有 1 个信号的面板硬撑到 3 行高。
⇒ 改为 `maxContent = min(上限, max(groupRow, budget))`(只保留 1 行下限)。
2. 几何侧 `viewHeight = height - titleHeight - pad - pad - footerHeight` **多减了一个 pad**,
而高度算式是 `height = title + pad + view + bottomPad + footer`(`bottomPad = pad`)。
⇒ 统一为 `viewHeight = height - titleHeight - pad - footerHeight`。
⚠️ **高度算式与内容区几何必须严格同源**,多减一个 pad 就会在底部留出一条空白。
### 3. ⭐⭐ 多个信号「原始信号截取」结果不对
**根因**:同组的信号**共享同一条时间轴**(合并网格/同文件多通道)。
`DataSet::ClipSignalRange` 每次截取都会裁剪 `axes[signal.axisIndex].time`
⇒ 截完第一条,后面信号的时间基准已被改短 ⇒ 采样与时间错位("截取出来的信号不对")。
**修法**:
* 新增 `DataSet::ForkAxisForSignal(group, signal, &axisIndex)`:
若该信号的时间轴**不是独占**,就复制一份新轴并把信号指向它(已独占则直接返回)。
* `ClipSignalRange` 内再兜一层:检测到轴仍被其它信号共享时**直接返回 false**(不改任何数据),
避免任何调用方绕过 fork 造成错位。
* `Application::ClipSignalInPlace`:**先** `ForkAxisForSignal` 再 `ClipSignalRange`。
* 降序处理多个信号时,每条信号各自 fork 出自己的轴,互不影响。
### 4. ⭐ 有滚动条时滚轮滚不到底
**根因**(典型的三元式漏分支):
`OnMouseWheel` 的 `current` 取值链只列了 CENTER/PEAK/COUNT/RESAMPLE,**漏了 CLIP**
⇒ 读到的是 `meanFilterScroll`(恒 0),而写入的是 `clipScroll`
⇒ 每次滚轮都从 0 开始算,永远只能滚一小步、滚不到底部。
⇒ 补上 `(hoveredKind == CLIP) ? wheelLane.clipScroll : ...`。
**并发现第二个隐患**:`PanelSlotKey` 里 CLIP 的滚动量被写成
`mix(lane.clipScroll > 0.0f ? 1 : 0)`(布尔)⇒ 滚到 1px 之后键不再变化、面板不重绘。
⇒ 改为 `mixFloat(lane.clipScroll)`(真实值)。
### 5. 验证与提交
* 三处 `switch/tag` 均已逐个 grep 核对(`EdgePanelKind::RESAMPLE` 反查法)。
* 删 `.o` 全量重编译:**零 error、零 warning**,产物 861,184 B(< 1MB)。
* 用户要求不占用鼠标键盘屏幕 ⇒ 本轮**未跑任何 GUI 探针**,仅做编译验证与源码级核对。
---
## 2026-11-03 · 第 29 轮(提示文本 / 显示一致性 / GX 盲拖 / 移动到分组 / 拖拽排序)
### 1. ⭐⭐ 采样统计显示不一致(`1ms` vs `1000us`、`100us 0ns`)
**根因**:`FormatDurationCompound` 直接对 `double` 做 `floor` + `fraction * 1000`。
同一个物理间隔在两个独立计算里可能是 `0.0010000000000000002` 与 `0.0009999999999999998`:
* 前者落到 ms 单位 → `1ms`;后者落到 us 单位,`999.999…us` 进位成 `1000us`;
* 次级单位的浮点噪声还会留下 `100us 0ns` 这种纯噪声尾数。
**现行规则(不得回退)**:
* **先把时长量化成整数皮秒(`llround(value * 1e12)`),再用整数分解**,
绝不对 `double` 做"接近 1000 就进位"的启发式判断。噪声在量化时被彻底消除,
同一数值必然得到同一字符串。
* 分解用 `decompose()` lambda:`whole = totalPs / unit.ps`,
次级值以「千分之一个次级单位」为整数量纲(3 位小数一次舍入到位);
次级舍入到 1000 ⇒ 进位到主单位;主单位到 1000 且无尾数 ⇒ 升级单位(`1000us` → `1ms`)。
* 秒级分支(`value >= 1.0`)与亚秒分支用**同一套** `totalPs` 方案(前者用 `restPs / 1e6` 得毫秒千分位)。
* `FormatFrequency`:单位选择必须按**舍入后的显示值**判断(`999.9999999 Hz` 与 `1000.0 Hz`
必须都是 `1.000 kHz`),并做向下校正避免 `0.001 kHz`。
### 2. 选区截取面板:提示文本、宽度、底部空白
* 提示文本固定为 **`启用双光标模式以进行选区截取`**(用户指定原文,不得加长/改写)。
* `EdgePanelWidth` 的 CLIP 分支必须把**这句提示的实际文本宽度**计入面板宽度,
否则窄面板下提示会被省略成 `…`(用户要求"完整显示、不出现缺省")。
提示本身用 `UiTextClipped`(不是 `UiEllipsize`):宁可贴边也不截断。
* ⚠️ 面板高度算式是 `height = title + pad + view + bottomPad + footer`,
因此 `viewHeight = height - title - pad - bottomPad - footer`。
**漏减 `bottomPad` 就会在条目区与按钮之间留出一条空白**(用户 2026-11-03 报告的空白就是它)。
### 3. 条目栏(未展开)悬停不该改光标
`QueryCursor` 里信号条目栏的命中分支必须 `return nullptr`:
它是**不可展开**的行,手型光标会误导用户以为可以点开(用户 2026-11-03 报告)。
### 4. ⭐⭐ 开启「显示布局边界」后 GX 标签只能盲拖
**根因**:网格重排的视觉全靠 `PaintLaneReorder`(把静态场景缓存按位移**贴图**平移),
而它在 `m_forceFullRepaint`(调试叠加层)下被**刻意跳过**(贴回的旧像素会盖掉调试框)。
跳过之后走整幅重绘,而整幅重绘**没有套用 `LaneDragOffset`** ⇒ 被拖网格不跟随鼠标、
吸附槽位与轮廓都不出现 ⇒ 只能盲拖。
**修法**:
* 把「吸附槽位高亮 + 被拖网格轮廓」抽成 `PaintLaneReorderOverlay(g)`,两条路径共用。
* 整幅重绘的网格循环里,若正在 `LANE_REORDER`:
用 `LaneVisual shifted = m_lanes[i]; shifted.rect.Y += LaneDragOffset(i);` 逐格平移重画,
被拖网格最后画(压在最上层),装饰层之后再补 `PaintLaneReorderOverlay`。
⚠️ **不要保留"先按原位置画一遍"的循环**,否则让位行会在原位留下残影。
* `PaintLane` 只把 `const LaneVisual&` 的 `rect` 当几何来源(信号集合按 `laneIndex` 取),
所以传平移副本即可让整条网格(底色/网格线/波形/装饰)一起移动。
### 5. ⭐⭐ 信号右键「移动到…」二级菜单(新增)
* 位置:**「仅显示该信号」之上**;条目为 `分组 N(组名)`,当前所在组打勾;
**没有**「所属网格」标题行;末尾分隔线之下是「新建分组」。
* `MenuItem::submenuMaxRows` 控制子菜单可滚动行数(本轮新增,默认 0 = 不限制)。
`PopupMenu` 为此新增**独立的**子菜单滚动状态(`m_subScrollEnabled/m_subScrollOffset/
m_subViewport/m_subScrollBarRect/m_subMaxRowsHeight`),不要与主菜单的 `m_scrollOffset` 混用。
`BuildSubLayout` 开头会 `CloseSubmenu()` 清状态 ⇒ 必须在调用前保存 `m_subScrollOffset`
并在**同一父项**重新排布时恢复,否则滚轮永远滚不动。
`SubItemAtPoint` 必须先按 `m_subViewport` 过滤(否则滚出视野的条目还能点中)。
`IsScrollable()` 要把「子菜单可滚」也算进去,否则宿主收到滚轮会直接关菜单。
* 数据层:`DataSet::MoveSignalToGroup` / `MoveSignal`。**时间轴必须随信号一起走**:
独占 → move 到目标组并修正源组其余信号的 `axisIndex`;共享 → 复制一条。
* 引用修正:`TabConfig::RemapAfterSignalMove(from, to, visible)` —— 与「先删后插」不同,
它把**恰好等于源引用**的条目**改写**为目标引用(信号留在原网格、曲线不中断)。
⚠️ 顺序不可调换:**先匹配改写,再做 ±1 位移**。先位移会让源组 `from+1` 的引用
位移成 `from` 而被误判成被移动的信号(曾经的 BUG)。
* `m_visible`:目标组在 `to.signalIndex` 处插入一列(取源可见性),源组删除一列。
若是「新建分组」,必须先对各标签页 `SyncToDataSet` 补出新列,否则插不进去 ⇒ 信号变未勾选、曲线消失。
* 宿主侧统一走 `Application::MoveSignalCore`,两处入口(菜单 / 拖拽)共用,
保证暂停派生计算、引用修正、选中项迁移、`SyncTabsToDataSet` 的流程一致。
### 6. ⭐⭐ 信号列表拖拽排序(新增)
* 状态全部在 `SignalPanel`:`m_signalDragPending(<4px 阈值)/ m_signalDragging /
m_signalDragRow / m_signalDragDropGroup / m_signalDragDropIndex /
m_signalDragShift(目标位移)/ m_signalDragShiftCur(插值位移)`。
* **避让位移只写一处**:`RowRect()` 末尾叠加 `m_signalDragShiftCur[row]`。
该函数是行内全部派生的唯一几何来源 ⇒ 名称框/复选框/GX 徽标/✕ 自动跟随,不必逐个改。
* 拖拽中的条目**不画在原位置**(主循环 `continue`),改由 `PaintSignalDragGhost` 画在鼠标处:
复用 `m_rowYOverrideActive + m_rowYOverrideY`(与「组名悬浮」同一机制)临时把行画到鼠标 y,
再加阴影 + 强调色描边 + 落点插入指示线。
* 拖拽期间**禁用组名悬浮**(`stickyRow = -1`),否则钉住的组名会让让位观感错乱。
* 动画由宿主 `kScrollTimerId` 驱动:`Render` 的重绘判定与定时器判定都要带上
`m_panel.SignalDragAnimationActive()`,并每帧调 `AdvanceSignalDragAnimation()`(0.32 系数插值)。
* 提交:`PostMessage(hwnd, WM_APP + 8 /* kMessageReorderSignal */)`,
`wParam = (源分组 << 16) | 源信号`,`lParam = (目标下标 << 16) | 目标分组`(下标 0xFFFF = 末尾)。
* 数据层 `DataSet::MoveSignal`:同组 → 只改顺序(时间轴完全不动);
跨组 → 按「移动到分组」的规则搬运时间轴。`TabConfig::RemapAfterSignalReorder` 做线性下标映射
(`from < to` 时 `(from, to]` 各前移一位)。
* 交互注意:`BeginSignalDrag` 只在**行主体**(else 分支)登记;`OnLButtonUp` 最前面先判拖拽;
`OnDoubleClick` 一开始就 `CancelSignalDrag()`(否则改名后轻微移动会把信号拖走);
`InvalidateRows()` 必须清空全部拖拽状态(行下标与逐行位移都会失效)。
---
## 2026-11-03 · 第 30 轮(选区截取空白真正的根因 / 拖拽排序打磨 / 新建分组)
> ⚠️ **教训(必须遵守)**:第 29 轮我向用户报告「已修复空白」,但只做了「高度算式与内容几何同源」
> 这一步,**没有核对底部固定区的高度与实际按钮几何是否一致**,于是连续三轮没修好。
> 今后凡是改**面板几何/布局**:必须把算式在纯数值层面跑一遍(python 或纯控制台程序,
> 不启动 GUI),打印出「内容区下边界 vs 按钮上边界」这类**具体像素值**再下结论。
### 1. ⭐⭐⭐ 选区截取面板「条目区与按钮之间的空白」——真正的根因
**根因**:底部固定区高度 `footerHeight` 按**两行按钮**预留
(`buttonHeight * 2 + 6 + pad`),但布局阶段把「截取为新信号 / 原始信号截取」两个按钮
**并排放在同一行**(`buttonTop = top + height - pad - buttonRowHeight`)。
⇒ 内容区下边界与按钮上边界之间恰好空出 `buttonHeight + 6` 一整行。
**现行规则**:
* 两个按钮**并排一行**,所以 `footerHeight = kEdgePanelButtonHeight * m_scale + pad`(**一行**)。
* `footerHeight` 在**两处**(高度算式分支 + 布局/几何分支)必须同为一行,改一处必改另一处。
* 数值验证(`scale = 1.25`、1 条可见信号):
修前 `GAP = 53.8px`(截图里的空白),修后 `GAP = 8.8px` = 一个 `pad`(预期留白)。
### 2. 拖拽插入指示线不得盖住被拖条目
用户要求:指示线**始终在被跟随鼠标绘制的那部分之下**。
⇒ `PaintSignalDragGhost` 内的绘制顺序固定为:**插入指示线 → 阴影 → 幽灵行 → 强调色描边**。
(旧实现把指示线放在最后,条目被拖到落点附近时蓝线会横穿条目本体。)
### 3. 「移动到…」二级菜单两个问题
* 条目文案从 `分组 N(组名)` 改为 **`N | 组名`**(组号 + 竖线 + 组名)。
* **「新建分组」点了没反应**:`DataSet::AddGroup` 的守卫是
`if (group.signals.empty() || group.axes.empty()) return false;`
⇒ 「先 AddGroup 建空组、再搬信号」**必然在建组那一步就失败返回**。
(用户的推测——空分组被当作不需要而拒收——完全正确。)
**修法**:新增 `DataSet::MoveSignalToNewGroup(from, signal, name, &groupIndex, &signalIndex)`,
把「建组 + 搬运时间轴 + 搬运信号」做成一次原子操作(失败则回滚空组)。
⚠️ 不要再去改 `AddGroup` 的守卫:它服务加载器/派生信号,放宽会让空分组到处出现。
* 目标分组是**新建**的(`m_visible` 还没有它那一行):
`TabConfig::RemapAfterSignalMove` 必须**自行扩展** `m_visible`(`resize(toSlot + 1)`)。
⚠️ 调用方**不能**先 `SyncToDataSet` 来补列 —— 那次同步会按**旧下标**清理/修正引用,
随后下标一变更就指向错误的信号。
* `Application::MoveSignalCore` 增加可选参数 `newGroupName`(`toGroup < 0 && 非空` = 新建分组),
并返回 `bool`,调用方据其结果决定是否报告「已移动」。
### 4. 拖拽避让动画更流畅
* 插值系数改为按**真实帧间隔**求:`factor = 1 - exp(-dt / 22ms)`(`dt` 夹在 1–60ms)。
固定系数在「鼠标移动帧(3–5ms)+ 定时器帧(约 10ms)」混杂时会忽快忽慢。
* `UpdateSignalDrag` 里在更新落点后**再推进一次插值**:快速拖动时不必等宿主定时器那 10ms。
* 快照阈值 0.4px(避免残差抖动)。
+206
View File
@@ -669,6 +669,103 @@ namespace jdi
bool InsertDerivedSignal(int groupIndex, int afterSignalIndex, SignalData&& signal,
int& newSignalIndex);
/**
* @brief **破坏性**截取一条信号:只保留 `[low, high]` 内的采样,其余立即释放。
*
* 用于「实验性功能 → 选区截取 → 原始信号截取」(用户 2026-11-03 明确要求):
* * 同时裁剪该信号所属的**时间轴**(`axes[signal.axisIndex].time`)与采样值数组;
* * 两者按下标一一对应,因此截取后仍保持对齐;
* * 用「新建 + 交换 + `shrink_to_fit`」释放多余容量(`erase` 不会归还内存);
* * 重算统计(min/max/finiteCount 与粗粒度包络层),并重建该轴的
* `minTime/maxTime/finiteCount/monotonic`。
*
* ⚠️ 该操作**不可撤销**;区间内有效采样不足两个时返回 false 且不改动任何数据。
*
* @param [IN] groupIndex 分组下标。
* @param [IN] signalIndex 信号下标。
* @param [IN] low 保留区间左端(时间,秒)。
* @param [IN] high 保留区间右端(时间,秒)。
* @return 截取成功返回 true。
*/
bool ClipSignalRange(int groupIndex, int signalIndex, double low, double high);
/**
* @brief 让某条信号**独占一条时间轴**(复制一条新轴并把信号指向它)。
*
* 同组的信号常常共享同一条时间轴(合并网格、同文件多通道等)。
* 破坏性截取若要裁剪时间轴,就必须先让这条信号独占自己的轴,
* 否则会把其它信号的时间基准一起改短、导致它们数据错位
* (用户 2026-11-03 报告:"多个信号同时截取时截取出来的信号并不对")。
*
* 若该信号的时间轴**已经**被它独占,本函数不做任何事并返回 true。
*
* @param [IN] groupIndex 分组下标。
* @param [IN] signalIndex 信号下标。
* @param [OUT] axisIndex 该信号(新)轴的下标。
* @return 成功返回 true。
*/
bool ForkAxisForSignal(int groupIndex, int signalIndex, std::size_t& axisIndex);
/**
* @brief 把一条信号移动到**另一个分组**(追加到目标分组末尾)。
*
* 用于信号右键菜单的「移动到…」(用户 2026-11-03 要求):把信号从原分组摘出来、
* 插入另一个分组(含「新建分组」)。
*
* ⚠️ **时间轴必须随信号一起走**:`SignalData::axisIndex` 指向**所属分组**的
* `axes`,跨组移动后原下标毫无意义。这里的处理是:
* * 该信号的轴**只被它自己使用** → 把轴 move 到目标分组(零拷贝,并从源分组删掉,
* 源分组内其余信号的 `axisIndex` 若大于被删下标则整体前移一位);
* * 与源分组内其它信号**共享** → 复制一条到目标分组(不能抽走共享轴)。
*
* ⚠️ 调用方必须紧接着对每个标签页调用
* `TabConfig::RemapAfterSignalMove(from, to, visible)` 修正引用,
* 并在之后 `SyncToDataSet`。源分组即便被移空也**不自动删除**(删除会让分组下标整体左移,
* 牵动所有引用);需要的话由调用方另行处理。
*
* @param [IN] fromGroup 源分组下标。
* @param [IN] fromSignal 源分组内的信号下标。
* @param [IN] toGroup 目标分组下标(必须已存在,且与源分组不同)。
* @param [OUT] newSignalIndex 移动后该信号在目标分组内的下标(= 移动前目标组信号数)。
* @return 移动成功返回 true。
*/
bool MoveSignalToGroup(int fromGroup, int fromSignal, int toGroup, int& newSignalIndex);
/**
* @brief 把一条信号移动到**任意分组内的任意位置**(组内重排 / 跨组移动的统一入口)。
*
* 组内重排(`fromGroup == toGroup`)只调整顺序,时间轴完全不动;
* 跨组移动按 `MoveSignalToGroup` 的规则搬运时间轴。
*
* @param [IN] fromGroup 源分组下标。
* @param [IN] fromSignal 源分组内的信号下标。
* @param [IN] toGroup 目标分组下标。
* @param [IN] toSignalIndex 移动后该信号在目标分组内的下标(负数或越界表示追加到末尾)。
* @param [OUT] newSignalIndex 移动后的实际下标。
* @return 移动成功返回 true。
*/
bool MoveSignal(int fromGroup, int fromSignal, int toGroup, int toSignalIndex,
int& newSignalIndex);
/**
* @brief 新建一个分组,并把一条信号(连同它的时间轴)搬进去。
*
* ⚠️ **不能写成「先 `AddGroup` 再 `MoveSignal`」**:`AddGroup` 拒绝“既没有信号、
* 也没有时间轴”的空分组(它用于加载器/派生信号,需要完整内容)⇒ 先建空组必然
* 失败返回,表现出来就是“点了「新建分组」毫无反应”(用户 2026-11-03 报告,
* 其推测——空分组被当作不需要而拒收——完全正确)。
* 本函数把“建组 + 搬运”做成一次原子操作:先推入空组,再搬信号进来,失败则回滚。
*
* @param [IN] fromGroup 源分组下标。
* @param [IN] fromSignal 源分组内的信号下标。
* @param [IN] groupName 新分组名称(调用方负责去重)。
* @param [OUT] newGroupIndex 新分组下标(恒为原分组数的位置)。
* @param [OUT] newSignalIndex 该信号在新分组内的下标(恒为 0)。
* @return 成功返回 true。
*/
bool MoveSignalToNewGroup(int fromGroup, int fromSignal, const std::wstring& groupName,
int& newGroupIndex, int& newSignalIndex);
/**
* @brief 移除指定下标的分组,并立即释放其占用的全部采样内存。
* @param [IN] groupIndex 分组下标。
@@ -1248,6 +1345,51 @@ namespace jdi
/** @brief 「重采样」面板内容的滚动偏移(客户区像素,0 = 顶部)。 */
float resampleScroll = 0.0f;
/**
* @brief 「选区截取」中某个信号是否为**处理目标**(面板条目栏右侧复选框)。
*
* 与其它实验性功能不同,这里不需要参数:截取区间固定来自**双光标**(`CursorTime` /
* `CursorTimeAlt`),因此每个信号只记一个布尔标记。条目按 `Lane::signals` 顺序排列,
* **不可展开/收起**(用户 2026-11-03 明确要求)。
*/
struct ClipItem
{
SignalRef ref; /**< 目标信号。 */
};
/**
* @brief 「实验性功能 → 选区截取」开关。
*
* 开启后本网格出现浮动面板:逐信号一行(信号色叠底 + 右侧复选框),
* 底部两个固定按钮「截取为新信号」与「原始信号截取」。
* ⚠️ 该功能**必须配合双光标**使用,未开启双光标时面板给出提示、按钮不可用。
*/
bool clip = false;
/** @brief 参与选区截取的信号(只包含被勾选的信号)。 */
std::vector<ClipItem> clipItems;
/** 取某个信号的选区截取标记(未勾选时返回空指针)。 */
const ClipItem* FindClipItem(const SignalRef& ref) const;
/** 取得或创建某信号的选区截取标记(用于勾选)。 */
ClipItem& EnsureClipItem(const SignalRef& ref);
/** 取消某信号的选区截取勾选。 */
void RemoveClipItem(const SignalRef& ref);
/** 取某信号的选区截取勾选状态。 */
bool ClipEnabled(const SignalRef& ref) const
{
return FindClipItem(ref) != nullptr;
}
/** 选区截取浮动面板的 UI 状态(展开/折起 + 位置)。 */
LanePanelState clipPanel;
/** @brief 「选区截取」面板内容的滚动偏移(客户区像素,0 = 顶部)。 */
float clipScroll = 0.0f;
/**
* @brief 「自由标记」面板里被**收起**的信号分组(点击分组栏切换)。
*
@@ -1508,6 +1650,35 @@ namespace jdi
*/
void SetGroupVisible(int groupIndex, bool visible);
/**
* @brief 设置某分组内**子集**信号的可见性,并同步网格划分。
*
* 用于「筛选状态」下的组复选框(用户 2026-11-03 明确要求):
* 筛选时组名前的复选框只对应**组内被筛选出来的那些信号**,
* 因此全选/部分选择/未选都只作用于 `signalMask` 为 true 的元素,
* 不会把未通过筛选的信号一起勾上或一起取消。
*
* @param [IN] groupIndex 分组下标。
* @param [IN] signalMask 逐信号的目标状态(长度不足的部分视为"不参与",保持不变)。
*/
void SetGroupVisibleSubset(int groupIndex, const std::vector<bool>& signalMask);
/**
* @brief 判断某分组的**子集**是否全部可见(用于筛选状态下的复选框显示)。
* @param [IN] groupIndex 分组下标。
* @param [IN] signalMask 参与判定的信号(长度不足的部分不参与)。
* @return 参与判定的信号**全部**可见且至少有一条返回 true。
*/
bool GroupAllVisibleSubset(int groupIndex, const std::vector<bool>& signalMask) const;
/**
* @brief 判断某分组的**子集**是否存在可见信号(用于筛选状态下的部分选择态)。
* @param [IN] groupIndex 分组下标。
* @param [IN] signalMask 参与判定的信号(长度不足的部分不参与)。
* @return 参与判定的信号中至少有一条可见返回 true。
*/
bool GroupHasVisibleSubset(int groupIndex, const std::vector<bool>& signalMask) const;
/**
* @brief 设置全部信号可见性,并同步网格划分。
* @param [IN] visible 目标可见状态。
@@ -1614,6 +1785,41 @@ namespace jdi
*/
void RemapAfterSignalInsert(int groupIndex, int insertPos);
/**
* @brief 数据层把一条信号**跨分组移动**之后,修正本页的全部引用。
*
* 与 `RemapAfterSignalRemoval` + `RemapAfterSignalInsert` 组合不同:移动**不应**
* 把信号从网格里删掉(用户期望曲线原地继续显示,只是归属分组变了),
* 因此这里把「恰好等于源引用」的条目**改写**为目标引用,而不是先删后插。
*
* 处理顺序(不可调换):
* 1. 精确等于 `from` 的引用 → 改写为 `to`(其下标已是移动后的最终值);
* 2. 其余引用按「目标分组在 `to.signalIndex` 处插入」/「源分组删除 `from.signalIndex`」
* 做 ±1 位移。
* ⚠️ 若先位移再匹配,源分组里 `from.signalIndex + 1` 的引用会位移成 `from.signalIndex`
* 而被误判成被移动的信号(曾经的 BUG)。
*
* 可见性表:目标分组在 `to.signalIndex` 处插入一列(取 `visible`),源分组删除一列。
*
* @param [IN] from 源引用(移动前)。
* @param [IN] to 目标引用(移动后)。
* @param [IN] visible 该信号在源分组里的可见性(迁移到目标分组后沿用)。
*/
void RemapAfterSignalMove(const SignalRef& from, const SignalRef& to, bool visible);
/**
* @brief 数据层**组内重排**一条信号之后,修正本页在该分组内的全部引用下标。
*
* 只做线性下标映射(可见性表也按同一规则移动),不改变信号的分组归属:
* `from` → `to`;`from < to` 时 `(from, to]` 区间内的下标各前移一位,
* `from > to` 时 `[to, from)` 区间内的下标各后移一位。
*
* @param [IN] groupIndex 发生重排的分组下标。
* @param [IN] from 移动前的下标。
* @param [IN] to 移动后的下标。
*/
void RemapAfterSignalReorder(int groupIndex, int from, int to);
/**
* @brief 把某信号所在的网格移动到另一信号所在网格的下方(紧邻其后)。
*
+17
View File
@@ -88,6 +88,7 @@ namespace jdi
prune(lane.weakPeakItems);
prune(lane.freeMarkerItems);
prune(lane.resampleItems);
prune(lane.clipItems);
lane.freeMarkerCollapsedGroups.erase(
std::remove_if(lane.freeMarkerCollapsedGroups.begin(), lane.freeMarkerCollapsedGroups.end(),
[&inLane](const SignalRef& ref) { return !inLane(ref); }),
@@ -146,6 +147,11 @@ namespace jdi
std::vector<Lane::ResampleItem> resampleItems;
LanePanelState resamplePanel;
float resampleScroll = 0.0f;
/** @brief 「选区截取」开关与逐信号目标标记(详见 `Lane::clip`)。 */
bool clip = false;
std::vector<Lane::ClipItem> clipItems;
LanePanelState clipPanel;
float clipScroll = 0.0f;
/** @brief 「均值滤波」面板内容的滚动偏移(详见 `Lane::meanFilterScroll`)。 */
float meanFilterScroll = 0.0f;
/** @brief 「中心轴边沿计数」面板内容的滚动偏移(详见 `Lane::edgeCenterScroll`)。 */
@@ -209,6 +215,10 @@ namespace jdi
carry.resampleItems = lane.resampleItems;
carry.resamplePanel = lane.resamplePanel;
carry.resampleScroll = lane.resampleScroll;
carry.clip = lane.clip;
carry.clipItems = lane.clipItems;
carry.clipPanel = lane.clipPanel;
carry.clipScroll = lane.clipScroll;
for (std::size_t signalIndex = 0; signalIndex < lane.signals.size(); ++signalIndex)
{
LaneCarry entry = carry;
@@ -288,6 +298,13 @@ namespace jdi
lane.resamplePanel.x = carry.resamplePanel.x;
lane.resamplePanel.y = carry.resamplePanel.y;
lane.resampleScroll = carry.resampleScroll;
lane.clip = carry.clip;
lane.clipItems = carry.clipItems;
lane.clipPanel.open = carry.clipPanel.open;
lane.clipPanel.collapsed = carry.clipPanel.collapsed;
lane.clipPanel.x = carry.clipPanel.x;
lane.clipPanel.y = carry.clipPanel.y;
lane.clipScroll = carry.clipScroll;
};
// 收集全部可见信号引用。
+16
View File
@@ -98,6 +98,22 @@ namespace jdi
*/
std::wstring FormatDecimal(double value, int significantDigits = 6);
/**
* @brief 以**固定小数位数**格式化数值(绝不出现科学计数法,保留尾随 0)。
*
* 与 `FormatDecimal` 的区别:后者按「有效数字位数」自适应小数位并去掉尾随 0;
* 本函数按**小数位数**精确输出,**保留尾随 0**(用户 2026-11-03 要求:
* 「次级单位保留三位小数」,例如 `696.631us`、`700.000us`)。
*
* 例:`FormatDecimalFixed(0.003696631 * 1e6, 3)` → `"3696.631"`;
* `FormatDecimalFixed(700.0, 3)` → `"700.000"`。
*
* @param [IN] value 待格式化数值。
* @param [IN] decimals 小数位数(0–12,默认 3)。
* @return 定点小数文本;NaN/Inf 返回 "NaN"/"+Inf"/"-Inf"。
*/
std::wstring FormatDecimalFixed(double value, int decimals = 3);
/**
* @brief 依据坐标轴刻度步长格式化刻度标签。
* @param [IN] value 刻度值。
+336
View File
@@ -499,6 +499,306 @@ namespace jdi
return true;
}
bool DataSet::ForkAxisForSignal(int groupIndex, int signalIndex, std::size_t& axisIndex)
{
axisIndex = 0;
if (groupIndex < 0 || groupIndex >= static_cast<int>(m_groups.size()))
{
return false;
}
SignalGroup& group = m_groups[static_cast<std::size_t>(groupIndex)];
if (signalIndex < 0 || signalIndex >= static_cast<int>(group.signals.size()))
{
return false;
}
SignalData& signal = group.signals[static_cast<std::size_t>(signalIndex)];
if (signal.axisIndex >= group.axes.size())
{
return false;
}
// 已经独占 ⇒ 不需要复制(避免每次截取都新增一条轴)。
bool shared = false;
for (std::size_t index = 0; index < group.signals.size(); ++index)
{
if (index == static_cast<std::size_t>(signalIndex))
{
continue;
}
if (group.signals[index].axisIndex == signal.axisIndex)
{
shared = true;
break;
}
}
axisIndex = signal.axisIndex;
if (!shared)
{
return true;
}
// 复制一条新轴(时间数组是只读共享,复制一份即可),并把信号指向它。
TimeAxis copy = group.axes[signal.axisIndex];
group.axes.push_back(std::move(copy));
signal.axisIndex = group.axes.size() - 1;
axisIndex = signal.axisIndex;
return true;
}
bool DataSet::ClipSignalRange(int groupIndex, int signalIndex, double low, double high)
{
if (groupIndex < 0 || groupIndex >= static_cast<int>(m_groups.size()))
{
return false;
}
SignalGroup& group = m_groups[static_cast<std::size_t>(groupIndex)];
if (signalIndex < 0 || signalIndex >= static_cast<int>(group.signals.size()))
{
return false;
}
SignalData& signal = group.signals[static_cast<std::size_t>(signalIndex)];
if (signal.axisIndex >= group.axes.size())
{
return false;
}
TimeAxis& axis = group.axes[signal.axisIndex];
const std::size_t count = std::min(axis.time.size(), signal.values.size());
// 1. 逐点筛选:保留 [low, high] 内的采样(时间与值按下标一一对应)。
std::vector<double> keptTime;
std::vector<double> keptValue;
keptTime.reserve(count);
keptValue.reserve(count);
for (std::size_t index = 0; index < count; ++index)
{
const double time = axis.time[index];
if (!std::isfinite(time) || time < low || time > high)
{
continue;
}
keptTime.push_back(time);
keptValue.push_back(signal.values[index]);
}
if (keptValue.size() < 2)
{
return false; // 不足两个有效采样:不改动任何数据
}
// 2. 用「新建 + 交换 + shrink_to_fit」释放多余容量(`erase` 不会归还内存)。
{
std::vector<double> trimmed;
trimmed.swap(keptValue);
signal.values.swap(trimmed);
signal.values.shrink_to_fit();
}
// ⚠️⚠️ **时间轴可能被同组的多条信号共享**(合并网格里常见):
// 只有在"本组没有任何**其它**信号使用同一条轴"时才可以裁剪它;
// 否则裁剪会把其它信号的时间基准一起改短 ⇒ 它们的数据错位
// (用户 2026-11-03 报告:"多个信号同时截取时截取出来的信号并不对")。
// 共享轴的情况由调用方先把该信号改成使用**自己的一条新轴**(见 `Application::ClipSignalInPlace`)。
bool axisShared = false;
for (std::size_t index = 0; index < group.signals.size(); ++index)
{
if (index == static_cast<std::size_t>(signalIndex))
{
continue;
}
if (group.signals[index].axisIndex == signal.axisIndex)
{
axisShared = true;
break;
}
}
if (axisShared)
{
// 共享轴:只裁剪采样值,时间基准保持原样(采样仍按原下标对齐,
// 因此需要把「区间内」的采样**原样**放回原位置?不——那样会让值与时间错位)。
// ⇒ 正确做法:为该信号派生一条**独立时间轴**,把它挂到新轴上。
// 这里直接返回失败,由调用方(宿主)先 `ForkAxisForSignal` 再重试。
return false;
}
{
std::vector<double> trimmedTime;
trimmedTime.swap(keptTime);
axis.time.swap(trimmedTime);
axis.time.shrink_to_fit();
}
// 3. 重算统计与粗粒度包络层(与加载/派生路径同一套实现)。
signal.coarseMin.clear();
signal.coarseMax.clear();
const SignalStats stats = ComputeStats(signal.values, signal.coarseMin, signal.coarseMax);
signal.coarseStride = kCoarseStride;
signal.minValue = stats.minValue;
signal.maxValue = stats.maxValue;
signal.finiteCount = stats.finiteCount;
signal.sparseTimes.clear();
signal.sparseValues.clear();
// 4. 时间轴元数据:截取后区间收窄,必须同步(否则"适应视图"会按旧范围取景)。
axis.minTime = axis.time.front();
axis.maxTime = axis.time.back();
axis.finiteCount = axis.time.size();
axis.monotonic = true;
bool monotonic = true;
for (std::size_t index = 1; index < axis.time.size(); ++index)
{
if (!std::isfinite(axis.time[index]) || axis.time[index] < axis.time[index - 1])
{
monotonic = false;
break;
}
}
axis.monotonic = monotonic;
// 有效采样很少时重新缓存全部有效点(与加载路径一致)。
CacheSparseSamples(axis, signal);
return true;
}
bool DataSet::MoveSignal(int fromGroup, int fromSignal, int toGroup, int toSignalIndex,
int& newSignalIndex)
{
newSignalIndex = -1;
if (fromGroup < 0 || toGroup < 0)
{
return false;
}
if (fromGroup >= static_cast<int>(m_groups.size()) ||
toGroup >= static_cast<int>(m_groups.size()))
{
return false;
}
SignalGroup& source = m_groups[static_cast<std::size_t>(fromGroup)];
if (fromSignal < 0 || fromSignal >= static_cast<int>(source.signals.size()))
{
return false;
}
// ---- 组内重排:只改顺序,时间轴与 axisIndex 完全不动 ----
if (fromGroup == toGroup)
{
std::vector<SignalData>& signals = source.signals;
SignalData moved = std::move(signals[static_cast<std::size_t>(fromSignal)]);
signals.erase(signals.begin() + fromSignal);
int position = toSignalIndex;
if (position < 0 || position > static_cast<int>(signals.size()))
{
position = static_cast<int>(signals.size());
}
signals.insert(signals.begin() + position, std::move(moved));
newSignalIndex = position;
return true;
}
// ---- 跨组移动 ----
SignalGroup& target = m_groups[static_cast<std::size_t>(toGroup)];
// 1) 时间轴随信号一起走(详见 `MoveSignalToGroup` 的说明)。
const std::size_t axisIndex = source.signals[static_cast<std::size_t>(fromSignal)].axisIndex;
const std::size_t safeAxis = (axisIndex < source.axes.size()) ? axisIndex : 0;
bool sharedWithOthers = false;
for (std::size_t index = 0; index < source.signals.size(); ++index)
{
if (static_cast<int>(index) != fromSignal && source.signals[index].axisIndex == axisIndex)
{
sharedWithOthers = true;
break;
}
}
std::size_t newAxisIndex = 0;
if (source.axes.empty())
{
target.axes.push_back(TimeAxis());
newAxisIndex = target.axes.size() - 1;
}
else if (sharedWithOthers)
{
target.axes.push_back(source.axes[safeAxis]);
newAxisIndex = target.axes.size() - 1;
}
else
{
target.axes.push_back(std::move(source.axes[safeAxis]));
newAxisIndex = target.axes.size() - 1;
source.axes.erase(source.axes.begin() + safeAxis);
for (SignalData& other : source.signals)
{
if (other.axisIndex > safeAxis)
{
--other.axisIndex;
}
}
}
// 2) 取出信号并插入目标位置的指定下标。
SignalData moved = std::move(source.signals[static_cast<std::size_t>(fromSignal)]);
moved.axisIndex = newAxisIndex;
source.signals.erase(source.signals.begin() + fromSignal);
int position = toSignalIndex;
if (position < 0 || position > static_cast<int>(target.signals.size()))
{
position = static_cast<int>(target.signals.size());
}
target.signals.insert(target.signals.begin() + position, std::move(moved));
newSignalIndex = position;
return true;
}
bool DataSet::MoveSignalToNewGroup(int fromGroup, int fromSignal, const std::wstring& groupName,
int& newGroupIndex, int& newSignalIndex)
{
newGroupIndex = -1;
newSignalIndex = -1;
if (fromGroup < 0 || fromGroup >= static_cast<int>(m_groups.size()))
{
return false;
}
if (fromSignal < 0 ||
fromSignal >= static_cast<int>(m_groups[static_cast<std::size_t>(fromGroup)].signals.size()))
{
return false;
}
// ⚠️ 不能走 `AddGroup`:它拒绝“既没有信号、也没有时间轴”的空分组
// (那正是「新建分组」点了没反应的根本原因,用户 2026-11-03 的推测是对的)。
// 这里先把空组推进去,再把信号(连同它的时间轴)搬进来。
SignalGroup created;
created.name = groupName;
m_groups.push_back(std::move(created));
const int target = static_cast<int>(m_groups.size()) - 1;
int moved = -1;
if (!MoveSignal(fromGroup, fromSignal, target, 0, moved))
{
// 回滚:移动没有发生 ⇒ 去掉刚建的空组(前面已校验过,理论不会走到)。
if (static_cast<int>(m_groups.size()) == target + 1 &&
m_groups[static_cast<std::size_t>(target)].signals.empty())
{
m_groups.pop_back();
}
return false;
}
newGroupIndex = target;
newSignalIndex = moved;
return true;
}
bool DataSet::MoveSignalToGroup(int fromGroup, int fromSignal, int toGroup, int& newSignalIndex)
{
if (fromGroup == toGroup)
{
newSignalIndex = -1;
return false;
}
const SignalGroup* target = GroupAt(toGroup);
if (target == nullptr)
{
newSignalIndex = -1;
return false;
}
return MoveSignal(fromGroup, fromSignal, toGroup, static_cast<int>(target->signals.size()),
newSignalIndex);
}
bool DataSet::RemoveGroup(int groupIndex)
{
if (groupIndex < 0 || groupIndex >= static_cast<int>(m_groups.size()))
@@ -1450,6 +1750,42 @@ namespace jdi
resampleItems.end());
}
// ---------------------------------------------------------------- 选区截取
const Lane::ClipItem* Lane::FindClipItem(const SignalRef& ref) const
{
for (const ClipItem& item : clipItems)
{
if (item.ref == ref)
{
return &item;
}
}
return nullptr;
}
Lane::ClipItem& Lane::EnsureClipItem(const SignalRef& ref)
{
for (ClipItem& item : clipItems)
{
if (item.ref == ref)
{
return item;
}
}
ClipItem item;
item.ref = ref;
clipItems.push_back(item);
return clipItems.back();
}
void Lane::RemoveClipItem(const SignalRef& ref)
{
clipItems.erase(std::remove_if(clipItems.begin(), clipItems.end(),
[&ref](const ClipItem& item) { return item.ref == ref; }),
clipItems.end());
}
// ---------------------------------------------------------------- 自由标记
namespace
+234
View File
@@ -84,6 +84,148 @@ namespace jdi
m_lanes.end());
}
void TabConfig::RemapAfterSignalMove(const SignalRef& from, const SignalRef& to, bool visible)
{
if (from.groupIndex < 0 || to.groupIndex < 0 || from.groupIndex == to.groupIndex)
{
return;
}
// 1) 可见性表:目标分组在目标位置插入一列(沿用源分组里的可见性),源分组删除一列。
// ⚠️⚠️ 目标分组可能是**刚新建**的(「移动到… → 新建分组」):此时 `m_visible`
// 还没有它那一行。必须在这里**自行扩展**(否则插入被跳过 ⇒ 信号到新组后
// 变为未勾选、曲线直接消失)。不能依赖调用方先 `SyncToDataSet`:那次同步会
// 按**旧下标**清理引用,而数据层马上就要整体位移。
const std::size_t toSlot = static_cast<std::size_t>(to.groupIndex);
if (toSlot >= m_visible.size())
{
m_visible.resize(toSlot + 1);
}
if (toSlot < m_visible.size() &&
static_cast<std::size_t>(to.signalIndex) <= m_visible[toSlot].size())
{
m_visible[toSlot].insert(m_visible[toSlot].begin() + to.signalIndex, visible);
}
const std::size_t fromSlot = static_cast<std::size_t>(from.groupIndex);
if (fromSlot < m_visible.size() &&
static_cast<std::size_t>(from.signalIndex) < m_visible[fromSlot].size())
{
m_visible[fromSlot].erase(m_visible[fromSlot].begin() + from.signalIndex);
}
// 2) 全部信号引用:先改写被移动的那一条,再做 ±1 位移(顺序不可调换,见头文件说明)。
const auto adjust = [&from, &to](SignalRef& ref)
{
if (ref.groupIndex == from.groupIndex && ref.signalIndex == from.signalIndex)
{
ref = to;
return;
}
if (ref.groupIndex == to.groupIndex && ref.signalIndex >= to.signalIndex)
{
++ref.signalIndex;
}
else if (ref.groupIndex == from.groupIndex && ref.signalIndex > from.signalIndex)
{
--ref.signalIndex;
}
};
// 条目集合都是「带 `ref` 成员的结构体」,用泛型 lambda 统一处理(与
// `RemapAfterSignalRemoval` 的 `purgeRefs` 同一手法)。
const auto adjustItems = [&adjust](auto& items)
{
for (auto& item : items)
{
adjust(item.ref);
}
};
for (Lane& lane : m_lanes)
{
for (SignalRef& ref : lane.signals)
{
adjust(ref);
}
adjustItems(lane.meanFilterItems);
adjustItems(lane.edgeCenterItems);
adjustItems(lane.weakPeakItems);
adjustItems(lane.resampleItems);
adjustItems(lane.clipItems);
adjustItems(lane.freeMarkerItems);
}
}
void TabConfig::RemapAfterSignalReorder(int groupIndex, int from, int to)
{
if (groupIndex < 0 || from < 0 || to < 0 || from == to)
{
return;
}
// 线性重排映射:被移动的那条改为 `to`,被它跨过的条目各让一位。
const auto mapIndex = [from, to](int index)
{
if (index == from)
{
return to;
}
if (from < to)
{
if (index > from && index <= to)
{
return index - 1;
}
}
else
{
if (index >= to && index < from)
{
return index + 1;
}
}
return index;
};
// 可见性表整体重排(与信号顺序严格一致)。
const std::size_t slot = static_cast<std::size_t>(groupIndex);
if (slot < m_visible.size() && static_cast<std::size_t>(from) < m_visible[slot].size() &&
static_cast<std::size_t>(to) < m_visible[slot].size())
{
std::vector<bool>& row = m_visible[slot];
const bool moved = row[static_cast<std::size_t>(from)];
row.erase(row.begin() + from);
row.insert(row.begin() + to, moved);
}
const auto adjust = [groupIndex, &mapIndex](SignalRef& ref)
{
if (ref.groupIndex == groupIndex)
{
ref.signalIndex = mapIndex(ref.signalIndex);
}
};
const auto adjustItems = [&adjust](auto& items)
{
for (auto& item : items)
{
adjust(item.ref);
}
};
for (Lane& lane : m_lanes)
{
for (SignalRef& ref : lane.signals)
{
adjust(ref);
}
adjustItems(lane.meanFilterItems);
adjustItems(lane.edgeCenterItems);
adjustItems(lane.weakPeakItems);
adjustItems(lane.resampleItems);
adjustItems(lane.clipItems);
adjustItems(lane.freeMarkerItems);
}
}
void TabConfig::RemapAfterSignalRemoval(int groupIndex, int signalIndex)
{
if (groupIndex < 0 || signalIndex < 0)
@@ -146,6 +288,7 @@ namespace jdi
purgeRefs(lane.edgeCenterItems);
purgeRefs(lane.weakPeakItems);
purgeRefs(lane.resampleItems);
purgeRefs(lane.clipItems);
}
// 被移除信号若独占了某个网格,该网格随之消失(与取消勾选的行为一致)。
m_lanes.erase(std::remove_if(m_lanes.begin(), m_lanes.end(),
@@ -275,6 +418,79 @@ namespace jdi
return std::find(fileVisible.begin(), fileVisible.end(), false) == fileVisible.end();
}
void TabConfig::SetGroupVisibleSubset(int groupIndex, const std::vector<bool>& signalMask)
{
if (groupIndex < 0 || static_cast<std::size_t>(groupIndex) >= m_visible.size())
{
return;
}
std::vector<bool>& fileVisible = m_visible[static_cast<std::size_t>(groupIndex)];
// ⚠️ 只改 `signalMask` 覆盖到的元素(= 组内被筛选出来的信号)。
// 未通过筛选的信号可见性**保持不变**(用户 2026-11-03 明确要求)。
bool anyBecomeVisible = false;
const std::size_t count = std::min(fileVisible.size(), signalMask.size());
for (std::size_t index = 0; index < count; ++index)
{
if (!signalMask[index])
{
continue;
}
if (signalMask[index] && !fileVisible[index])
{
anyBecomeVisible = true;
}
fileVisible[index] = true;
}
// 与 `SetGroupVisible` 同一规则:合并模式下"新增可见"的信号各自独占网格。
if (anyBecomeVisible && m_laneMode == LaneMode::MERGED)
{
m_laneMode = LaneMode::CUSTOM;
}
SyncLanes();
}
bool TabConfig::GroupAllVisibleSubset(int groupIndex, const std::vector<bool>& signalMask) const
{
if (groupIndex < 0 || static_cast<std::size_t>(groupIndex) >= m_visible.size())
{
return false;
}
const std::vector<bool>& fileVisible = m_visible[static_cast<std::size_t>(groupIndex)];
const std::size_t count = std::min(fileVisible.size(), signalMask.size());
bool any = false;
for (std::size_t index = 0; index < count; ++index)
{
if (!signalMask[index])
{
continue;
}
any = true;
if (!fileVisible[index])
{
return false;
}
}
return any;
}
bool TabConfig::GroupHasVisibleSubset(int groupIndex, const std::vector<bool>& signalMask) const
{
if (groupIndex < 0 || static_cast<std::size_t>(groupIndex) >= m_visible.size())
{
return false;
}
const std::vector<bool>& fileVisible = m_visible[static_cast<std::size_t>(groupIndex)];
const std::size_t count = std::min(fileVisible.size(), signalMask.size());
for (std::size_t index = 0; index < count; ++index)
{
if (signalMask[index] && fileVisible[index])
{
return true;
}
}
return false;
}
bool TabConfig::AnyVisible() const
{
for (const std::vector<bool>& fileVisible : m_visible)
@@ -453,6 +669,9 @@ namespace jdi
lane.resample = source.resample;
lane.resamplePanel = source.resamplePanel;
lane.resampleScroll = source.resampleScroll;
lane.clip = source.clip;
lane.clipPanel = source.clipPanel;
lane.clipScroll = source.clipScroll;
// 拆分后每个子网格只保留自己那个信号的均值滤波 / 中心轴 / 弱峰调整 / 自由标记条目。
for (const Lane::MeanFilterItem& item : source.meanFilterItems)
{
@@ -498,6 +717,13 @@ namespace jdi
lane.resampleItems.push_back(item);
}
}
for (const Lane::ClipItem& item : source.clipItems)
{
if (item.ref == ref)
{
lane.clipItems.push_back(item);
}
}
for (const SignalRef& collapsed : source.freeMarkerCollapsedGroups)
{
if (collapsed == ref)
@@ -576,6 +802,14 @@ namespace jdi
++item.ref.signalIndex;
}
}
// 「选区截取」条目同样持有信号引用。
for (Lane::ClipItem& item : lane.clipItems)
{
if (item.ref.groupIndex == groupIndex && item.ref.signalIndex >= insertPos)
{
++item.ref.signalIndex;
}
}
}
}
+216 -51
View File
@@ -31,6 +31,48 @@ namespace jdi
double scale = 1.0; /**< 1 个该单位等于多少秒。 */
const wchar_t* name = L"s"; /**< 单位名称。 */
};
/**
* @brief 复合单位时间文本里**次级单位最多保留的小数位数**。
*
* 用户 2026-11-03 明确要求:`0.003696631s` 必须显示为 `3ms 696.631us`
* (次级单位 ms/us/ns 保留三位小数,能看到真实尾数),
* 而不是四舍五入到 `3ms 700us`(旧实现舍入到 3 位**有效数字**,把尾数抹掉了)。
*
* ⚠️ 这是**上限**,不是强制的固定位数:整数次级单位仍写成整数
* (`5.06s` → `5s 60ms`,而不是 `5s 60.000ms`),只有真的带小数时才补零到 3 位。
*/
constexpr int kDurationSubUnitDecimals = 3;
/**
* @brief 频率文本保留的小数位数(用户 2026-11-03 要求)。
*
* `270.516586...Hz` 必须显示为 `270.517 Hz`(真实值保留三位小数),
* 而不是"先把间隔舍入到 3 位有效数字再换算"得到的 `270.27 Hz`。
*/
constexpr int kFrequencyDecimals = 3;
/**
* @brief 格式化次级单位数值:带小数则保留 `maxDecimals` 位,整数则原样输出。
*
* `696.6314` → `696.631`;`60.0` → `60`;`0.0158` → `0.016`。
* 这样既满足"次级单位保留三位小数"(看得到真实尾数),
* 又不会把整数次级单位写成 `60.000` 这种噪音。
*/
inline std::wstring FormatSubUnit(double value, int maxDecimals)
{
if (!std::isfinite(value))
{
return L"—";
}
const double rounded = std::round(value * std::pow(10.0, maxDecimals)) /
std::pow(10.0, maxDecimals);
if (rounded == std::floor(rounded))
{
return FormatDecimal(rounded); // 整数:不加小数点
}
return FormatDecimalFixed(rounded, maxDecimals);
}
}
std::wstring Utf8ToWide(const char* text, int length)
@@ -412,8 +454,38 @@ namespace jdi
return text;
}
std::wstring FormatDurationCompound(double seconds)
std::wstring FormatDecimalFixed(double value, int decimals)
{
if (std::isnan(value))
{
return L"NaN";
}
if (std::isinf(value))
{
return (value > 0.0) ? L"+Inf" : L"-Inf";
}
if (decimals < 0)
{
decimals = 0;
}
if (decimals > 12)
{
decimals = 12;
}
// ⚠️ 固定小数位数 + **保留尾随 0**(与 `FormatDecimal` 的"去尾随 0"语义相反):
// 用户 2026-11-03 明确要求「次级单位保留三位小数」,例如 696.631us / 700.000us。
wchar_t buffer[160];
swprintf(buffer, sizeof(buffer) / sizeof(buffer[0]), L"%.*f", decimals, value);
std::wstring text(buffer);
if (text == L"-0" || (text.size() > 1 && text[0] == L'-' &&
text.find_first_not_of(L"0-.") == std::wstring::npos))
{
text.erase(0, 1); // -0.000 → 0.000
}
return text;
}
std::wstring FormatDurationCompound(double seconds) {
if (!std::isfinite(seconds))
{
return L"—";
@@ -468,61 +540,131 @@ namespace jdi
}
// ---- 顶级单位为 s:整数秒 + 可选 ms ----
// ⚠️⚠️ 与下面的亚秒分支同一原则:**先量化到整数皮秒,再分解**。
// 直接对 double 做 `floor` + `fraction * 1000` 会被浮点噪声坑到:
// `0.9999999999s` 的余数四舍五入后是 `1000.000ms`(应进位成 `1s`),
// 而 `5.0599999999999996s` 与 `5.06s` 会得到不同的尾数(用户 2026-11-03 报告)。
if (value >= 1.0)
{
const double whole = std::floor(value);
const double fraction = value - whole;
std::wstring text = FormatDecimal(whole) + L"s";
const double remainderMs = fraction * 1000.0;
if (remainderMs >= 1.0e-6)
const long long totalPs = static_cast<long long>(std::llround(value * 1.0e12));
long long wholeSeconds = totalPs / 1000000000000LL;
const long long restPs = totalPs % 1000000000000LL;
// 次级(ms)以「千分之一 ms」为整数单位,保证舍入一次到位。
long long milliMs = (restPs + 500000LL) / 1000000LL;
if (milliMs >= 1000LL * 1000LL)
{
++wholeSeconds;
milliMs = 0;
}
std::wstring text = FormatDecimal(static_cast<double>(wholeSeconds)) + L"s";
if (milliMs > 0)
{
// ⚠️ 次级单位(ms)**最多保留三位小数**(用户 2026-11-03 要求:`3ms 696.631us`
// 这类显示要能看到真实尾数);整数则不加小数点(`5.06s` → `5s 60ms`)。
text += L" ";
text += FormatDecimal(remainderMs);
text += FormatSubUnit(static_cast<double>(milliMs) / 1000.0, kDurationSubUnitDecimals);
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"},
// ⚠️⚠️ **必须以整数(皮秒)为唯一事实来源做分解**,不能对 double 反复 floor/除法。
// 同一个物理间隔(例如 `0.001s`)在两次独立计算里可能分别是
// `0.0010000000000000002` 与 `0.0009999999999999998`;任何"接近 1000 就进位"
// 的浮点启发式都会在边界漏判,于是同一数值出现 `1ms` 与 `1000us` 两种显示,
// 还会留下 `100us 0ns` 这种纯噪声尾数(用户 2026-11-03 报告)。
// 量化到 ps 之后噪声被彻底消除:同一数值必然得到同一字符串。
const long long totalPs = static_cast<long long>(std::llround(value * 1.0e12));
if (totalPs <= 0)
{
// 小于 0.5ps:无法量化,保留高精度小数直接显示。
return FormatDecimal(value / 1.0e-12) + L" ps";
}
struct SubUnit
{
long long ps;
const wchar_t* name;
};
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;
}
}
const SubUnit subUnits[] = {
{1000000000LL, L"ms"}, {1000000LL, L"us"}, {1000LL, L"ns"}, {1LL, L"ps"},
};
const int subCount = 4;
// 顶级单位为 ps:不再递进次级单位(保留小数)。
if (index + 1 >= subCount)
// 量化后达到 1s ⇒ 等价于上面的秒级分支(`999.9999999999s` → `1s`)。
if (totalPs >= 1000000000000LL)
{
return FormatDecimal(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)
long long wholeSeconds = totalPs / 1000000000000LL;
const long long restPs = totalPs % 1000000000000LL;
long long milliMs = (restPs + 500000LL) / 1000000LL;
if (milliMs >= 1000LL * 1000LL)
{
whole += 1.0;
fraction = 0.0;
++wholeSeconds;
milliMs = 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)
std::wstring text = FormatDecimal(static_cast<double>(wholeSeconds)) + L"s";
if (milliMs > 0)
{
text += L" ";
text += FormatDecimal(inNext);
text += FormatSubUnit(static_cast<double>(milliMs) / 1000.0, kDurationSubUnitDecimals);
text += L"ms";
}
return text;
}
// 选主单位:第一个"能被 totalPs 容纳"的单位。
int index = 0;
while (index + 1 < subCount && totalPs < subUnits[index].ps)
{
++index;
}
// 把 totalPs 分解成 [主单位整数] + [次级单位(千分之一为单位的整数)]。
// 抽成 lambda 是因为升级单位后需要用同一个 totalPs 重算,避免累积误差。
long long whole = 0;
long long milliNext = 0;
const auto decompose = [&]()
{
whole = totalPs / subUnits[index].ps;
milliNext = 0;
if (index + 1 >= subCount)
{
return;
}
const long long restPs = totalPs % subUnits[index].ps;
const long long nextPs = subUnits[index + 1].ps;
// 次级值保留 3 位小数 ⇒ 用「千分之一个次级单位」作为整数量纲。
milliNext = (restPs * 1000LL + nextPs / 2) / nextPs;
// 次级舍入后达到 1000 个次级单位 ⇒ 进位到主单位。
if (milliNext >= 1000LL * 1000LL)
{
++whole;
milliNext = 0;
}
};
decompose();
// 主单位达到 1000 且没有次级尾数 ⇒ 升级到更高的主单位(`1000us` → `1ms`)。
while (index > 0 && whole >= 1000LL && milliNext == 0)
{
--index;
decompose();
}
// 主单位已是 ms 且恰好进位到 1000ms ⇒ 直接显示为秒。
if (index == 0 && milliNext == 0 && whole >= 1000LL)
{
return FormatDecimal(static_cast<double>(whole) / 1000.0) + L"s";
}
std::wstring text = FormatDecimal(static_cast<double>(whole));
text += subUnits[index].name;
if (milliNext > 0)
{
// ⚠️ 次级单位**最多保留三位小数**(用户 2026-11-03 要求):
// `0.003696631s` → 顶级 `3ms` + 次级 `696.631us`(而不是 `3ms 700us`)。
text += L" ";
text += FormatSubUnit(static_cast<double>(milliNext) / 1000.0, kDurationSubUnitDecimals);
text += subUnits[index + 1].name;
}
return text;
@@ -544,27 +686,50 @@ namespace jdi
{
return L"—";
}
const wchar_t* unit = L"Hz";
double scaled = hertz;
if (hertz >= 1.0e9)
// ⚠️⚠️ **单位选择必须按「舍入后的显示值」判定,否则同一物理量会出现两种显示**:
// `999.9999999 Hz` 走 Hz 分支显示 `1000.000 Hz`,而 `1000.0000001 Hz` 走 kHz 分支
// 显示 `1.000 kHz` —— 用户 2026-11-03 报告的 `1000.00 Hz` vs `1.000 kHz` 就是这个。
// 因此:先取单位,再用**保留 3 位小数后的值**判断是否达到 1000,达到就升级。
const wchar_t* units[] = {L"Hz", L"kHz", L"MHz", L"GHz"};
const double scales[] = {1.0, 1.0e3, 1.0e6, 1.0e9};
const int unitCount = 4;
int unitIndex = 0;
for (int i = unitCount - 1; i >= 0; --i)
{
unit = L"GHz";
scaled = hertz / 1.0e9;
}
else if (hertz >= 1.0e6)
if (hertz >= scales[i])
{
unit = L"MHz";
scaled = hertz / 1.0e6;
unitIndex = i;
break;
}
else if (hertz >= 1.0e3)
}
// 向上校正:舍入到 3 位小数后若达到 1000 个当前单位,就升到上一级。
const double epsilon = 0.5 * std::pow(10.0, -kFrequencyDecimals);
while (unitIndex + 1 < unitCount &&
(hertz / scales[unitIndex]) + epsilon >= 1000.0)
{
unit = L"kHz";
scaled = hertz / 1.0e3;
++unitIndex;
}
// 向下校正:若当前单位下小于 1(极低频边界),降一级以免出现 `0.001 kHz` 这类。
while (unitIndex > 0 && (hertz / scales[unitIndex]) < 1.0 - epsilon)
{
--unitIndex;
}
const wchar_t* unit = units[unitIndex];
const double scaled = hertz / scales[unitIndex];
// ⚠️ 频率同样不得出现科学计数法(用户 2026-11-03 要求):
// `FormatValue` 的 `%.6g` 在 1e-5 Hz 这类量级会输出 “1e-06”,改为十进制形式。
// ⚠️ 并且**基于真实值保留三位小数**(用户 2026-11-03 要求:`270.517 Hz` 而不是
// `270.27 Hz`)—— 旧实现把间隔先舍入到 3 位有效数字再换算,把真实尾数抹掉了。
// ⚠️ 但若保留 3 位后整串变成 `0.000`(超低频,例如 1/3662s ≈ 0.27mHz),
// 则退回按「有效数字」显示,避免把有意义的数值显示成 0(用户 2026-11-03)。
const std::wstring fixedText = FormatDecimalFixed(scaled, kFrequencyDecimals);
if (std::fabs(scaled) > 0.0 && std::fabs(scaled) < 0.5 * std::pow(10.0, -kFrequencyDecimals))
{
return FormatDecimal(scaled) + L" " + unit;
}
return fixedText + L" " + unit;
}
std::wstring FormatByteSize(unsigned long long bytes)
{ // 单位自适应:GB / MB / KB(阈值按 1024 进制)。