From 62220477c02efa7cdc342c9689e2a33b1d2eacb8 Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Tue, 3 Nov 2026 16:04:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(clip):=20=E6=96=B0=E5=A2=9E=E3=80=8C?= =?UTF-8?q?=E9=80=89=E5=8C=BA=E6=88=AA=E5=8F=96=E3=80=8D=E5=AE=9E=E9=AA=8C?= =?UTF-8?q?=E6=80=A7=E5=8A=9F=E8=83=BD=20+=20=E7=AD=9B=E9=80=89=E6=80=81?= =?UTF-8?q?=E7=BB=84=E5=A4=8D=E9=80=89=E6=A1=86=E5=8F=AA=E4=BD=9C=E7=94=A8?= =?UTF-8?q?=E4=BA=8E=E7=AD=9B=E9=80=89=E7=BB=93=E6=9E=9C=20+=20=E9=87=8D?= =?UTF-8?q?=E9=87=87=E6=A0=B7=E7=BB=9F=E8=AE=A1=E4=B8=8D=E8=A2=AB=E7=9C=81?= =?UTF-8?q?=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一、重采样面板统计信息不再被省略为 `…` - 根因: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。 --- Components/Application/Inc/Application.hpp | 35 +++ .../Application/Src/ApplicationCommands.cpp | 207 ++++++++++++++++++ Components/PlotView/Inc/PlotView.hpp | 65 +++++- Components/PlotView/Inc/PlotViewInternal.hpp | 43 +++- Components/PlotView/Src/PlotViewCacheKey.cpp | 9 + Components/PlotView/Src/PlotViewFeatures.cpp | 114 ++++++++++ .../PlotView/Src/PlotViewInteraction.cpp | 122 ++++++++++- Components/PlotView/Src/PlotViewPaint.cpp | 68 +++++- Components/PlotView/Src/PlotViewPanels.cpp | 156 +++++++++---- Components/SignalPanel/Inc/SignalPanel.hpp | 15 ++ Components/SignalPanel/Src/SignalPanel.cpp | 20 ++ .../Src/SignalPanelInteraction.cpp | 30 ++- .../SignalPanel/Src/SignalPanelPaint.cpp | 33 ++- Documents/agent_mem.md | 90 ++++++++ Utilities/Inc/DataModel.hpp | 94 ++++++++ Utilities/Inc/DataModelInternal.hpp | 17 ++ Utilities/Src/DataModel.cpp | 120 ++++++++++ Utilities/Src/DataModelTab.cpp | 92 ++++++++ 18 files changed, 1263 insertions(+), 67 deletions(-) diff --git a/Components/Application/Inc/Application.hpp b/Components/Application/Inc/Application.hpp index 5e53bd1..52c2447 100644 --- a/Components/Application/Inc/Application.hpp +++ b/Components/Application/Inc/Application.hpp @@ -337,6 +337,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 把一条信号插入到「源信号」的下方,并同步各标签页的引用与可见性。 * diff --git a/Components/Application/Src/ApplicationCommands.cpp b/Components/Application/Src/ApplicationCommands.cpp index f9be786..d98b59a 100644 --- a/Components/Application/Src/ApplicationCommands.cpp +++ b/Components/Application/Src/ApplicationCommands.cpp @@ -949,6 +949,165 @@ 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(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(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 keptTimes; + std::vector 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; + } + const bool ok = m_dataSet.ClipSignalRange(groupIndex, signalIndex, low - timeShift, + high - timeShift); + if (!ok) + { + m_plot.ShowPersistentHint(L"选区截取失败:区间内的有效采样点不足两个。"); + return false; + } + SyncTabsToDataSet(); + return true; + } + bool Application::InsertSignalBelowSource(int groupIndex, int sourceIndex, SignalData&& signal, bool makeVisible, int& newIndex) { @@ -1165,6 +1324,42 @@ namespace jdi } } + // 「选区截取」:两个按钮(截取为新信号 / 原始信号截取)共用同一份区间。 + { + int laneIndex = -1; + std::vector 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 requests; @@ -1397,6 +1592,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); @@ -1701,6 +1904,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); diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 369fdb0..29b3f3e 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -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& 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 m_pendingWeakPeakItems; /**< 待构建的弱峰调整信号及其参数。 */ int m_pendingResampleLane = -1; /**< 待构建的重采样信号所属网格下标(-1 表示无请求)。 */ std::vector m_pendingResampleItems; /**< 待构建的重采样信号及其间隔。 */ + + int m_pendingClipLane = -1; /**< 待截取的信号所属网格下标(-1 表示无请求)。 */ + std::vector m_pendingClipItems; /**< 待截取的信号及其区间。 */ bool m_mouseOverPanel = false; /**< 上一帧鼠标是否落在某个实验性功能面板上。 */ int m_peakDragLane = -1; /**< 正在拖动弱峰滑块的面板所属网格。 */ SignalRef m_peakDragRef; /**< 正在拖动弱峰滑块的**信号**(每个信号一套参数)。 */ diff --git a/Components/PlotView/Inc/PlotViewInternal.hpp b/Components/PlotView/Inc/PlotViewInternal.hpp index a267f2d..577b014 100644 --- a/Components/PlotView/Inc/PlotViewInternal.hpp +++ b/Components/PlotView/Inc/PlotViewInternal.hpp @@ -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 未启用双光标模式时面板给出的提示。 + * + * 该功能依赖「双光标」定义的截取区间,因此单光标/无光标时无法工作。 + */ + 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(sizeof(kEdgePanelKinds) / sizeof(kEdgePanelKinds[0])); @@ -1342,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 颜色。 diff --git a/Components/PlotView/Src/PlotViewCacheKey.cpp b/Components/PlotView/Src/PlotViewCacheKey.cpp index 886fa3f..330e186 100644 --- a/Components/PlotView/Src/PlotViewCacheKey.cpp +++ b/Components/PlotView/Src/PlotViewCacheKey.cpp @@ -179,6 +179,15 @@ namespace jdi mix(static_cast(item.amountPercent)); mix(static_cast(item.iterations)); } + // 「选区截取」:开关 + 每个被勾选信号(只影响面板像素,不影响网格曲线内容, + // 但与其它面板一样放在这里以统一定义;面板自身另有 `PanelSlotKey`)。 + mix(Tab().Lanes()[laneIndex].clip ? 1ull : 0ull); + mix(static_cast(Tab().Lanes()[laneIndex].clipItems.size())); + for (const Lane::ClipItem& item : Tab().Lanes()[laneIndex].clipItems) + { + mix(static_cast(item.ref.groupIndex)); + mix(static_cast(item.ref.signalIndex)); + } } mix(signals.size()); for (const SignalRef& ref : signals) diff --git a/Components/PlotView/Src/PlotViewFeatures.cpp b/Components/PlotView/Src/PlotViewFeatures.cpp index e08d1b4..bb6b4f1 100644 --- a/Components/PlotView/Src/PlotViewFeatures.cpp +++ b/Components/PlotView/Src/PlotViewFeatures.cpp @@ -1990,6 +1990,115 @@ namespace jdi return true; } + // ============================ 「选区截取」 ============================ + + bool PlotView::SetLaneClip(int laneIndex, bool enabled) + { + if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) + { + return false; + } + Lane& lane = m_tab->Lanes()[static_cast(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(m_tab->Lanes().size())) + { + return false; + } + return m_tab->Lanes()[static_cast(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(m_tab->Lanes().size())) + { + return false; + } + Lane& lane = m_tab->Lanes()[static_cast(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(m_tab->Lanes().size())) + { + return false; + } + return m_tab->Lanes()[static_cast(laneIndex)].ClipEnabled(ref); + } + + bool PlotView::SetLaneClipScroll(int laneIndex, float scroll) + { + if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) + { + return false; + } + Lane& lane = m_tab->Lanes()[static_cast(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& 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(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; } diff --git a/Components/PlotView/Src/PlotViewInteraction.cpp b/Components/PlotView/Src/PlotViewInteraction.cpp index 4ef0ab3..b96cafe 100644 --- a/Components/PlotView/Src/PlotViewInteraction.cpp +++ b/Components/PlotView/Src/PlotViewInteraction.cpp @@ -1066,6 +1066,18 @@ namespace jdi { return LoadCursorW(nullptr, IDC_HAND); } + // 「选区截取」底部两个按钮:可点击控件(双光标关闭时为空矩形 ⇒ 不响应)。 + if (layout.clipBuildButton.Width > 0.0f && + UiPointInRect(layout.clipBuildButton, static_cast(x), static_cast(y))) + { + return LoadCursorW(nullptr, IDC_HAND); + } + if (layout.clipDestructiveButton.Width > 0.0f && + UiPointInRect(layout.clipDestructiveButton, static_cast(x), + static_cast(y))) + { + return LoadCursorW(nullptr, IDC_HAND); + } if (layout.opacityTrack.Width > 0.0f && WeakPeakSliderHit(layout.opacityTrack, m_scale, static_cast(x), static_cast(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(panelLane)]; if (listCursor) @@ -1136,6 +1149,10 @@ namespace jdi checked = cursorLane.FindResampleItem( layout.rowRefs[rowIndex]) != nullptr; } + else if (cursorKind == EdgePanelKind::CLIP) + { + checked = cursorLane.ClipEnabled(layout.rowRefs[rowIndex]); + } else { checked = cursorLane.FindMeanFilterItem( @@ -1143,6 +1160,10 @@ namespace jdi } return checked ? LoadCursorW(nullptr, IDC_HAND) : nullptr; } + if (cursorKind == EdgePanelKind::CLIP) + { + continue; // 截取面板的条目只有复选框,没有参数行 + } if (cursorKind == EdgePanelKind::RESAMPLE) { // 两行输入框是可点击控件(点击进入就地编辑)。 @@ -1734,6 +1755,10 @@ namespace jdi 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; @@ -1832,6 +1857,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; @@ -2014,7 +2042,8 @@ namespace jdi static_cast(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(x), static_cast(y))) @@ -2411,16 +2440,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) { @@ -2583,6 +2613,82 @@ namespace jdi return true; // 面板内的其它位置:消费点击,不穿透 } } + // 「选区截取」:条目只有**右侧复选框**(不可展开),底部两个固定按钮。 + if (clipMode) + { + const bool doubleCursor = (Tab().CursorMode() == PlotCursorMode::DOUBLE); + Lane& clipLane = m_tab->Lanes()[static_cast(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) @@ -3325,6 +3431,9 @@ namespace jdi // 用户只能改走右键菜单取消功能(用户 2026-11-03 报告)。 SetLaneResample(lane, false); break; + case EdgePanelKind::CLIP: + SetLaneClip(lane, false); + break; case EdgePanelKind::COUNT: default: SetLaneEdgeCount(lane, false); @@ -3457,7 +3566,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 && @@ -3495,6 +3604,9 @@ namespace jdi case EdgePanelKind::RESAMPLE: SetLaneResampleScroll(panelLane, target); break; + case EdgePanelKind::CLIP: + SetLaneClipScroll(panelLane, target); + break; default: SetLaneMeanFilterScroll(panelLane, target); break; diff --git a/Components/PlotView/Src/PlotViewPaint.cpp b/Components/PlotView/Src/PlotViewPaint.cpp index 895c13a..58a3bf2 100644 --- a/Components/PlotView/Src/PlotViewPaint.cpp +++ b/Components/PlotView/Src/PlotViewPaint.cpp @@ -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); @@ -4067,6 +4069,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 +4201,15 @@ 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); + mix(lane.clipScroll > 0.0f ? 1ull : 0ull); } // ⚠️⚠️ **边沿统计的数字本身就是面板内容,数字变化必须能让面板重绘**: @@ -4883,13 +4898,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 +5104,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 +5366,15 @@ namespace jdi : tabLane.FindMeanFilterItem(ref)); const Lane::ResampleItem* resampleItem = resamplePanel ? tabLane.FindResampleItem(ref) : nullptr; - const bool checked = resamplePanel ? (resampleItem != nullptr) - : (weakPeak ? (peakItem != nullptr) - : (item != nullptr)); + const bool checked = clipPanel ? tabLane.ClipEnabled(ref) + : (resamplePanel ? (resampleItem != nullptr) + : (weakPeak ? (peakItem != nullptr) + : (item != nullptr))); const bool rowCollapsed = - resamplePanel ? (resampleItem != nullptr && resampleItem->collapsed) - : (weakPeak ? (peakItem != nullptr && peakItem->collapsed) - : (item != nullptr && item->collapsed)); + clipPanel ? true + : (resamplePanel ? (resampleItem != nullptr && resampleItem->collapsed) + : (weakPeak ? (peakItem != nullptr && peakItem->collapsed) + : (item != nullptr && item->collapsed))); // 内容区已由 `g.SetClip(layout.content)` 裁切(行可以“半截”显示,滚动更顺滑); // 已经完整滚出可视范围的行在上面就 `continue` 了,这里不再额外门控。 @@ -5414,6 +5435,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 - @@ -6114,7 +6140,25 @@ 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 + { + // 提示文字不参与滚动,画在内容区正中。 + const std::wstring hintText(kClipNeedDoubleCursorHint); + UiTextClipped(g, UiEllipsize(hintText, m_fonts->uiSmall, layout.content.Width), + m_fonts->uiSmall, layout.content, m_theme->textMuted, + TextAlign::CENTER, layout.content); + } + } + if (hasButton && !clipPanel) { paintToggleButton(layout.button, kBuildSignalLabel, false); } diff --git a/Components/PlotView/Src/PlotViewPanels.cpp b/Components/PlotView/Src/PlotViewPanels.cpp index d414aa2..199f708 100644 --- a/Components/PlotView/Src/PlotViewPanels.cpp +++ b/Components/PlotView/Src/PlotViewPanels.cpp @@ -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,19 +169,35 @@ 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")), - UiMeasureTextWidth(m_fonts->monoSmall, L"1h 1min 2s(1.23456 GHz)")); + // ⚠️ 逐信号实测「最大 / 最小数据间隔」的真实文本宽度(含(频率)尾缀), + // 而不是按 `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 + (kFreeMarkerScrollWidth + 4.0f) * m_scale); @@ -205,6 +222,16 @@ 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); + } + if (countTable) { // 「边沿计数」表格:右侧要给内容滚动条留位(上百个信号时内容必然超高)。 @@ -300,25 +327,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 - : (centerAxis ? lane.edgeCenter - : lane.edgeCount)))); + : (clipPanel ? lane.clip + : (centerAxis ? lane.edgeCenter + : lane.edgeCount))))); const bool open = meanFilter ? lane.meanFilterPanel.open : (weakPeak ? lane.weakPeakPanel.open : (freeMarker ? lane.freeMarkerPanel.open : (resamplePanel ? lane.resamplePanel.open - : (centerAxis - ? lane.edgeCenterPanel.open - : lane.edgePanel.open)))); + : (clipPanel + ? lane.clipPanel.open + : (centerAxis + ? lane.edgeCenterPanel.open + : lane.edgePanel.open))))); if (!enabled || !open) { return layout; @@ -329,9 +362,11 @@ namespace jdi : (freeMarker ? lane.freeMarkerPanel.collapsed : (resamplePanel ? lane.resamplePanel.collapsed - : (centerAxis - ? lane.edgeCenterPanel.collapsed - : lane.edgePanel.collapsed)))); + : (clipPanel + ? lane.clipPanel.collapsed + : (centerAxis + ? lane.edgeCenterPanel.collapsed + : lane.edgePanel.collapsed))))); layout.collapsed = collapsed; int rowCount = 0; @@ -356,7 +391,11 @@ namespace jdi // 因此不再有独立的底部编辑行;「边沿计数」走独立的**表格**分支。 const float buttonHeight = hasButton ? (kEdgePanelButtonHeight * m_scale) : 0.0f; // 面板底部(内容区之下)占的纵向空间:有按钮时 = pad + 按钮 + pad,无按钮时只有 pad。 - const float footerHeight = hasButton ? (buttonHeight + pad) : 0.0f; + // ⚠️ 「选区截取」底部是**两个**按钮行(截取为新信号 / 原始信号截取), + // 因此 footer 要按两行算(用户 2026-11-03 要求)。 + const float footerHeight = + clipPanel ? (buttonHeight * 2.0f + 6.0f * m_scale + pad) + : (hasButton ? (buttonHeight + pad) : 0.0f); const float bottomPad = pad; const float weakPeakSliderRow = kWeakPeakSliderRowHeight * m_scale; @@ -397,6 +436,10 @@ namespace jdi continue; } content += groupRow; + if (clipPanel) + { + continue; // 「选区截取」的条目**不可展开**,每条只占一行 + } bool expanded = false; if (resamplePanel) { @@ -534,17 +577,21 @@ namespace jdi : (freeMarker ? lane.freeMarkerPanel.x : (resamplePanel ? lane.resamplePanel.x - : (centerAxis - ? lane.edgeCenterPanel.x - : lane.edgePanel.x)))); + : (clipPanel + ? lane.clipPanel.x + : (centerAxis + ? lane.edgeCenterPanel.x + : lane.edgePanel.x))))); const double storedY = meanFilter ? lane.meanFilterPanel.y : (weakPeak ? lane.weakPeakPanel.y : (freeMarker ? lane.freeMarkerPanel.y : (resamplePanel ? lane.resamplePanel.y - : (centerAxis - ? lane.edgeCenterPanel.y - : lane.edgePanel.y)))); + : (clipPanel + ? lane.clipPanel.y + : (centerAxis + ? lane.edgeCenterPanel.y + : lane.edgePanel.y))))); int openPanelOrdinal = 0; { // ⚠️ 错开量用「**当前真正显示出来的面板**中排在本面板之前的个数」: @@ -570,12 +617,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 +787,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,7 +811,9 @@ 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 float footerHeight = + clipPanel ? (buttonRowHeight * 2.0f + 6.0f * m_scale + pad) + : (hasButton ? (buttonRowHeight + pad) : 0.0f); const float viewHeight = std::max(0.0f, height - titleHeight - pad - pad - footerHeight); const bool scrollable = meanContentHeight > viewHeight + 0.5f; @@ -754,7 +821,8 @@ namespace jdi 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)); @@ -812,7 +880,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,11 +915,12 @@ 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, - rowY + (groupRow - kEdgePanelRowTriangleSize * m_scale) * 0.5f, - kEdgePanelRowTriangleSize * m_scale, - kEdgePanelRowTriangleSize * m_scale) - : gd::RectF()); + (checked && !clipPanel) + ? gd::RectF(contentLeft + 3.0f * m_scale, + rowY + (groupRow - kEdgePanelRowTriangleSize * m_scale) * 0.5f, + kEdgePanelRowTriangleSize * m_scale, + kEdgePanelRowTriangleSize * m_scale) + : gd::RectF()); layout.rowChecks.push_back( gd::RectF(contentLeft + innerWidth - closeSize - 4.0f * m_scale, rowY + (groupRow - closeSize) * 0.5f, closeSize, closeSize)); @@ -856,7 +930,7 @@ namespace jdi rowY += groupRow; // 参数行:只有勾选且未收起时才有几何(未勾选的行两者都是空矩形)。 - if (!checked || itemCollapsed) + if (!checked || itemCollapsed || clipPanel) { layout.rowLevelTracks.push_back(gd::RectF()); layout.rowLevelValues.push_back(gd::RectF()); diff --git a/Components/SignalPanel/Inc/SignalPanel.hpp b/Components/SignalPanel/Inc/SignalPanel.hpp index a600fb5..cc1e908 100644 --- a/Components/SignalPanel/Inc/SignalPanel.hpp +++ b/Components/SignalPanel/Inc/SignalPanel.hpp @@ -1486,6 +1486,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& mask) const; + bool m_pendingLaneMenu = false; /**< 左键抬起时是否弹出网格归属菜单。 */ SignalRef m_pendingLaneRef; /**< 待弹出菜单的目标信号。 */ bool m_pendingColorPicker = false; /**< 左键抬起时是否弹出拾色器。 */ diff --git a/Components/SignalPanel/Src/SignalPanel.cpp b/Components/SignalPanel/Src/SignalPanel.cpp index 112e64f..3e6857a 100644 --- a/Components/SignalPanel/Src/SignalPanel.cpp +++ b/Components/SignalPanel/Src/SignalPanel.cpp @@ -262,6 +262,26 @@ namespace jdi return WildcardMatchNoCase(signal.name, pattern); } + bool SignalPanel::BuildFilterMask(int groupIndex, std::vector& 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) diff --git a/Components/SignalPanel/Src/SignalPanelInteraction.cpp b/Components/SignalPanel/Src/SignalPanelInteraction.cpp index b3919a9..54e0fd1 100644 --- a/Components/SignalPanel/Src/SignalPanelInteraction.cpp +++ b/Components/SignalPanel/Src/SignalPanelInteraction.cpp @@ -485,8 +485,34 @@ namespace jdi { if (part == RowPart::CHECK) { - const bool allVisible = m_tab->GroupAllVisible(item.groupIndex); - m_tab->SetGroupVisible(item.groupIndex, !allVisible); + // ⚠️ 筛选状态下组复选框**只作用于被筛选出来的信号**(用户 2026-11-03 明确要求): + // 全选时只勾上筛选结果,取消时也只取消筛选结果, + // 不会把未通过筛选的信号一起勾上/取消。 + std::vector 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(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; } diff --git a/Components/SignalPanel/Src/SignalPanelPaint.cpp b/Components/SignalPanel/Src/SignalPanelPaint.cpp index 34b3751..91993b5 100644 --- a/Components/SignalPanel/Src/SignalPanelPaint.cpp +++ b/Components/SignalPanel/Src/SignalPanelPaint.cpp @@ -341,8 +341,20 @@ namespace jdi mix(static_cast(item.displayCount + 1)); if (m_tab != nullptr) { - mix(m_tab->GroupAllVisible(item.groupIndex) ? 1ull : 0ull); - mix(m_tab->GroupHasVisible(item.groupIndex) ? 2ull : 0ull); + // ⚠️ 必须与绘制侧同源:筛选状态下复选框反映的是**筛选子集**的三态, + // 若仍用整组的 `GroupAllVisible` 入键,勾选筛选结果后缓存键不变 + // ⇒ 复选框不会重画(用户 2026-11-03 报告的问题之一)。 + std::vector 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); @@ -582,8 +594,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 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); diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index 739d5a8..eebe739 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -9862,3 +9862,93 @@ frequency 1/0.016182614 -> 61.795 Hz 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`"做全量重编译验证即可。 diff --git a/Utilities/Inc/DataModel.hpp b/Utilities/Inc/DataModel.hpp index 076b6ee..14a51e8 100644 --- a/Utilities/Inc/DataModel.hpp +++ b/Utilities/Inc/DataModel.hpp @@ -669,6 +669,26 @@ 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 移除指定下标的分组,并立即释放其占用的全部采样内存。 * @param [IN] groupIndex 分组下标。 @@ -1248,6 +1268,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 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 +1573,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& signalMask); + + /** + * @brief 判断某分组的**子集**是否全部可见(用于筛选状态下的复选框显示)。 + * @param [IN] groupIndex 分组下标。 + * @param [IN] signalMask 参与判定的信号(长度不足的部分不参与)。 + * @return 参与判定的信号**全部**可见且至少有一条返回 true。 + */ + bool GroupAllVisibleSubset(int groupIndex, const std::vector& signalMask) const; + + /** + * @brief 判断某分组的**子集**是否存在可见信号(用于筛选状态下的部分选择态)。 + * @param [IN] groupIndex 分组下标。 + * @param [IN] signalMask 参与判定的信号(长度不足的部分不参与)。 + * @return 参与判定的信号中至少有一条可见返回 true。 + */ + bool GroupHasVisibleSubset(int groupIndex, const std::vector& signalMask) const; + /** * @brief 设置全部信号可见性,并同步网格划分。 * @param [IN] visible 目标可见状态。 diff --git a/Utilities/Inc/DataModelInternal.hpp b/Utilities/Inc/DataModelInternal.hpp index 7e7cfe2..40d20ee 100644 --- a/Utilities/Inc/DataModelInternal.hpp +++ b/Utilities/Inc/DataModelInternal.hpp @@ -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 resampleItems; LanePanelState resamplePanel; float resampleScroll = 0.0f; + /** @brief 「选区截取」开关与逐信号目标标记(详见 `Lane::clip`)。 */ + bool clip = false; + std::vector 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; }; // 收集全部可见信号引用。 diff --git a/Utilities/Src/DataModel.cpp b/Utilities/Src/DataModel.cpp index ee00c33..159ceae 100644 --- a/Utilities/Src/DataModel.cpp +++ b/Utilities/Src/DataModel.cpp @@ -499,6 +499,90 @@ namespace jdi return true; } + bool DataSet::ClipSignalRange(int groupIndex, int signalIndex, double low, double high) + { + if (groupIndex < 0 || groupIndex >= static_cast(m_groups.size())) + { + return false; + } + SignalGroup& group = m_groups[static_cast(groupIndex)]; + if (signalIndex < 0 || signalIndex >= static_cast(group.signals.size())) + { + return false; + } + SignalData& signal = group.signals[static_cast(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 keptTime; + std::vector 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 trimmed; + trimmed.swap(keptValue); + signal.values.swap(trimmed); + signal.values.shrink_to_fit(); + } + { + std::vector 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::RemoveGroup(int groupIndex) { if (groupIndex < 0 || groupIndex >= static_cast(m_groups.size())) @@ -1450,6 +1534,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 diff --git a/Utilities/Src/DataModelTab.cpp b/Utilities/Src/DataModelTab.cpp index de8dc57..b90a70d 100644 --- a/Utilities/Src/DataModelTab.cpp +++ b/Utilities/Src/DataModelTab.cpp @@ -146,6 +146,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 +276,79 @@ namespace jdi return std::find(fileVisible.begin(), fileVisible.end(), false) == fileVisible.end(); } + void TabConfig::SetGroupVisibleSubset(int groupIndex, const std::vector& signalMask) + { + if (groupIndex < 0 || static_cast(groupIndex) >= m_visible.size()) + { + return; + } + std::vector& fileVisible = m_visible[static_cast(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& signalMask) const + { + if (groupIndex < 0 || static_cast(groupIndex) >= m_visible.size()) + { + return false; + } + const std::vector& fileVisible = m_visible[static_cast(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& signalMask) const + { + if (groupIndex < 0 || static_cast(groupIndex) >= m_visible.size()) + { + return false; + } + const std::vector& fileVisible = m_visible[static_cast(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& fileVisible : m_visible) @@ -453,6 +527,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 +575,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 +660,14 @@ namespace jdi ++item.ref.signalIndex; } } + // 「选区截取」条目同样持有信号引用。 + for (Lane::ClipItem& item : lane.clipItems) + { + if (item.ref.groupIndex == groupIndex && item.ref.signalIndex >= insertPos) + { + ++item.ref.signalIndex; + } + } } }