From f5028b1e189dfd5e32d1f1be9e0146a08260c18d Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Fri, 9 Oct 2026 21:00:38 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E9=AA=8C=E6=80=A7=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E3=80=8C=E5=9D=87=E5=80=BC=E6=BB=A4=E6=B3=A2?= =?UTF-8?q?=E3=80=8D+=20=E6=9E=84=E5=BB=BA=E4=B8=BA=E6=96=B0=E4=BF=A1?= =?UTF-8?q?=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 「实验性功能」二级菜单新增带勾选的「均值滤波」:开启后为本网格每条信号叠加绘制 一条 N 点滑动平均曲线,算法与「边沿计数(中心轴)」的中心平均值曲线**完全一致** (同一实现 ComputeMovingAverage),配色同样由信号色派生为高对比度颜色、透明度一致。 - 新增均值滤波浮动面板:标题栏可拖拽、✕ 关闭,面板内列出被处理的信号名、 「均值点数 N」内联编辑行,以及底部的「构建为新信号」按钮(悬停高亮 + 手型光标)。 - 「构建为新信号」:把滤波后的曲线作为新信号插入,命名为「<原信号名称> (N点均值)」, 归属到原信号所属组的原信号位置**下方**,成为独立的新信号;其所在网格也移动到 原信号网格的下方。合并网格时一次按钮为该网格内每条可见信号各构建一条。 为此新增 DataSet::InsertDerivedSignal / TabConfig::RemapAfterSignalInsert / TabConfig::MoveLaneOfSignalBelow / TabConfig::RenameLanes(插入位置之后的信号引用 与可见性列同步重映射)。 - 光标交互与「边沿计数(中心轴)」同构:光标处标注均值滤波曲线的取值 与双光标增量,文案为「值(均值滤波)」/「值(+增量 均值滤波)」; 两条派生曲线的取值胶囊已抽成同一段绘制逻辑。 - 浮动面板种类由 bool centerAxis 改为 EdgePanelKind{COUNT,CENTER,MEAN}, 布局/宽度/命中测试/绘制统一走同一套几何。 - 以 VERY_BIG_DATA.CSV 验证:曲线预览、N 编辑、双光标取值标注、 单信号与合并网格两种情况下的新信号命名与插入位置均正确。 --- Components/Application/Inc/Application.hpp | 21 + Components/Application/Src/Application.cpp | 118 +++++ Components/PlotView/Inc/PlotView.hpp | 88 +++- Components/PlotView/Src/PlotView.cpp | 569 +++++++++++++-------- Utilities/Inc/DataModel.hpp | 77 ++- Utilities/Src/DataModel.cpp | 222 +++++++- 6 files changed, 885 insertions(+), 210 deletions(-) diff --git a/Components/Application/Inc/Application.hpp b/Components/Application/Inc/Application.hpp index c156b5f..8ff25de 100644 --- a/Components/Application/Inc/Application.hpp +++ b/Components/Application/Inc/Application.hpp @@ -227,6 +227,27 @@ namespace jdi */ bool ApplyPendingRename(); + /** + * @brief 应用绘图区提交的「均值滤波 → 构建为新信号」请求。 + * + * 为指定网格内的每条可见信号各构建一条 N 点均值滤波信号,命名为 + * `<原信号名称> (N点均值)`,插入到原信号所在组的原信号位置**下方**, + * 并把新信号所在的网格移动到原信号网格的下方。 + * + * @return 实际构建了新信号返回 true。 + */ + bool ApplyPendingMeanFilterBuild(); + + /** + * @brief 把一条均值滤波派生信号插入数据层并同步各视图。 + * @param [IN] fileIndex 原信号所属文件下标。 + * @param [IN] signalIndex 原信号下标。 + * @param [IN] radius 均值滤波半径 N。 + * @param [IN] makeVisible 是否在当前标签页中勾选新信号。 + * @return 构建并插入成功返回 true。 + */ + bool BuildMeanFilterSignal(int fileIndex, int signalIndex, int radius, bool makeVisible); + /** * @brief 弹出文件选择对话框并加载所选文件。 */ diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index 3c3738e..703df33 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -1398,6 +1398,112 @@ namespace jdi return true; } + bool Application::BuildMeanFilterSignal(int fileIndex, int signalIndex, int radius, bool makeVisible) + { + const DataFile* file = m_dataSet.FileAt(fileIndex); + if (file == nullptr || signalIndex < 0 || signalIndex >= static_cast(file->signals.size())) + { + return false; + } + const SignalData& source = file->signals[static_cast(signalIndex)]; + const std::wstring baseName = source.Title(); + + // 1. 计算均值滤波采样值(与预览曲线共用同一实现)。 + std::vector filtered; + ComputeMovingAverage(source.values, radius, filtered); + + // 2. 组装新信号:名称形如「<原信号名称> (N点均值)」。 + SignalData derived; + derived.name = baseName + L" (" + std::to_wstring(radius) + L"点均值)"; + derived.rawHeader = derived.name; + derived.unit = source.unit; + derived.values.assign(filtered.begin(), filtered.end()); + + // 3. 插入到原信号的下方(数据层 + 各标签页引用重映射)。 + int newIndex = -1; + if (!m_dataSet.InsertDerivedSignal(fileIndex, signalIndex, std::move(derived), newIndex)) + { + return false; + } + for (TabConfig& tab : m_tabs) + { + tab.RemapAfterSignalInsert(fileIndex, newIndex); + } + + // 4. 在当前标签页中勾选新信号,并把它所在的网格移到原信号网格的下方。 + if (makeVisible && !m_tabs.empty()) + { + SignalRef derivedRef; + derivedRef.fileIndex = fileIndex; + derivedRef.signalIndex = newIndex; + ActiveTab().SetVisible(m_dataSet, derivedRef, true); + + SignalRef sourceRef; + sourceRef.fileIndex = fileIndex; + sourceRef.signalIndex = signalIndex; + ActiveTab().MoveLaneOfSignalBelow(derivedRef, sourceRef); + } + + // 5. 数据层结构变化:刷新列表、作废折线/派生缓存并重绘。 + SyncTabsToDataSet(); + return true; + } + + bool Application::ApplyPendingMeanFilterBuild() + { + int laneIndex = -1; + int radius = 0; + if (!m_plot.TakePendingMeanFilterBuild(laneIndex, radius)) + { + return false; + } + if (m_tabs.empty() || laneIndex < 0 || + laneIndex >= static_cast(ActiveTab().Lanes().size())) + { + return false; + } + + // 先收集该网格内的全部可见信号(插入过程中信号下标会变化,因此先取快照)。 + std::vector targets; + for (const SignalRef& ref : ActiveTab().Lanes()[static_cast(laneIndex)].signals) + { + if (m_dataSet.Resolve(ref) == nullptr || !ActiveTab().IsVisible(ref)) + { + continue; + } + targets.push_back(ref); + } + + // **同一文件内必须按下标降序处理**:每次插入只让插入点之后的信号下标 +1, + // 降序处理可保证尚未处理的信号下标始终有效(升序会把新插入的信号当成原信号)。 + std::sort(targets.begin(), targets.end(), + [](const SignalRef& a, const SignalRef& b) + { + if (a.fileIndex != b.fileIndex) + { + return a.fileIndex < b.fileIndex; + } + return a.signalIndex > b.signalIndex; + }); + + int built = 0; + for (const SignalRef& ref : targets) + { + if (BuildMeanFilterSignal(ref.fileIndex, ref.signalIndex, radius, true)) + { + ++built; + } + } + + if (built > 0) + { + SetStatusMessage(L"已构建 " + std::to_wstring(built) + L" 条 " + std::to_wstring(radius) + + L"点均值滤波信号。", + false); + } + return built > 0; + } + void Application::ShowTabContextMenu(const TabBarResult& result) { if (result.index < 0 || result.index >= static_cast(m_tabs.size())) @@ -1519,6 +1625,12 @@ namespace jdi edgeCenter.id = PLOT_CMD_LANE_EDGE_CENTER; edgeCenter.checked = m_plot.LaneEdgeCenterCount(laneIndex); experimental.submenu.push_back(edgeCenter); + + MenuItem meanFilter; + meanFilter.label = L"均值滤波"; + meanFilter.id = PLOT_CMD_LANE_MEAN_FILTER; + meanFilter.checked = m_plot.LaneMeanFilter(laneIndex); + experimental.submenu.push_back(meanFilter); } items.push_back(experimental); @@ -1629,6 +1741,10 @@ namespace jdi // 「实验性功能 → 边沿计数(中心轴)」:切换中心平均值曲线与统计面板。 m_plot.ToggleLaneEdgeCenterCount(laneIndex); break; + case PLOT_CMD_LANE_MEAN_FILTER: + // 「实验性功能 → 均值滤波」:切换均值滤波曲线与参数面板。 + m_plot.ToggleLaneMeanFilter(laneIndex); + break; case PLOT_CMD_LANE_SPLIT: // 「拆分当前网格」:信号按个拆成独立网格,新网格顺次插入其下方。 m_plot.SplitLane(laneIndex); @@ -2116,6 +2232,8 @@ namespace jdi { // 绘图区内的点击也可能提交图例改名,需要立刻落到数据层。 ApplyPendingRename(); + // 「均值滤波 → 构建为新信号」按钮:立即构建新信号并同步各视图。 + ApplyPendingMeanFilterBuild(); InvalidateRect(hwnd, nullptr, FALSE); } // 拖动期间 Windows 不再派发 WM_SETCURSOR,需在按下后立即刷新一次, diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 257598c..7d3e914 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -53,7 +53,8 @@ namespace jdi LANE_Y_MAX, /**< 编辑某网格的 Y 轴最大值。 */ LANE_Y_MIN, /**< 编辑某网格的 Y 轴最小值。 */ SIGNAL_NAME, /**< 编辑图例中某信号的显示名称。 */ - EDGE_CENTER_SMOOTH /**< 编辑「中心轴边沿计数」的平滑点数 N。 */ + EDGE_CENTER_SMOOTH, /**< 编辑「中心轴边沿计数」的平滑点数 N。 */ + MEAN_FILTER_RADIUS /**< 编辑「均值滤波」的均值点数 N。 */ }; /** @@ -69,7 +70,18 @@ namespace jdi PLOT_CMD_LANE_AUTO_VIEW = 6, /**< 切换本网格的「选区 Y 轴自适应」开关。 */ PLOT_CMD_LANE_EDGE_COUNT = 7, /**< 切换本网格的「实验性功能 → 边沿计数」开关。 */ PLOT_CMD_LANE_EDGE_CENTER = 8, /**< 切换本网格的「实验性功能 → 边沿计数(中心轴)」开关。 */ - PLOT_CMD_LANE_SPLIT = 9 /**< 拆分当前网格:把网格内信号按个拆成独立网格并顺次下移编号。 */ + PLOT_CMD_LANE_SPLIT = 9, /**< 拆分当前网格:把网格内信号按个拆成独立网格并顺次下移编号。 */ + PLOT_CMD_LANE_MEAN_FILTER = 10 /**< 切换本网格的「实验性功能 → 均值滤波」开关。 */ + }; + + /** + * @brief 实验性功能浮动面板的种类。 + */ + enum class EdgePanelKind : int + { + COUNT = 0, /**< 边沿计数。 */ + CENTER = 1, /**< 边沿计数(中心轴)。 */ + MEAN = 2 /**< 均值滤波。 */ }; /** @@ -462,6 +474,40 @@ namespace jdi */ bool ToggleLaneEdgeCenterCount(int laneIndex); + /** + * @brief 判断某网格是否开启了「实验性功能 → 均值滤波」。 + * @param [IN] laneIndex 网格下标。 + * @return 开启返回 true。 + */ + bool LaneMeanFilter(int laneIndex) const; + + /** + * @brief 设置某网格的「均值滤波」开关。 + * @param [IN] laneIndex 网格下标。 + * @param [IN] enabled 目标状态。 + * @return 状态发生变化返回 true。 + */ + bool SetLaneMeanFilter(int laneIndex, bool enabled); + + /** + * @brief 切换某网格的「均值滤波」开关。 + * @param [IN] laneIndex 网格下标。 + * @return 切换后的状态。 + */ + bool ToggleLaneMeanFilter(int laneIndex); + + /** + * @brief 取出「构建为新信号」请求(由面板底部按钮触发)。 + * + * 请求只描述「哪一网格、用多大的 N」,真正的信号构建与插入由宿主(Application) + * 完成——数据层属于 DataSet,绘图区不能直接修改它。 + * + * @param [OUT] laneIndex 触发该请求的网格下标。 + * @param [OUT] radius 均值滤波半径 N。 + * @return 存在待处理请求返回 true(取出后清空)。 + */ + bool TakePendingMeanFilterBuild(int& laneIndex, int& radius); + /** * @brief 进入某网格 Y 轴上限/下限的内联输入状态。 * @param [IN] laneIndex 网格下标。 @@ -913,7 +959,7 @@ namespace jdi bool IsOnEdgeCursorLine(int x, int y, int& laneIndex) const; /** - * @brief 实验性功能浮动面板的几何布局(边沿计数 / 中心轴边沿计数共用同一套样式)。 + * @brief 实验性功能浮动面板的几何布局(三类面板共用同一套样式)。 */ struct EdgePanelLayout { @@ -921,16 +967,17 @@ namespace jdi gd::RectF title; /**< 标题栏(**唯一可拖拽区域**)。 */ gd::RectF close; /**< ✕ 关闭按钮。 */ gd::RectF edit; /**< 面板底部编辑行(无编辑内容时宽度为 0)。 */ + gd::RectF button; /**< 面板底部按钮(无按钮时宽度为 0)。 */ int rows = 0; /**< 统计行数(每条可见信号一行)。 */ }; /** * @brief 计算某个实验性功能面板的布局。 * @param [IN] laneIndex 网格下标。 - * @param [IN] centerAxis 为 true 时取「边沿计数(中心轴)」面板,否则取「边沿计数」。 + * @param [IN] kind 面板种类。 * @return 布局;面板未展开时各项均为空矩形。 */ - EdgePanelLayout EdgePanelLayoutFor(int laneIndex, bool centerAxis) const; + EdgePanelLayout EdgePanelLayoutFor(int laneIndex, EdgePanelKind kind) const; /** * @brief 获取「边沿计数(中心轴)」浮动面板的矩形。 @@ -984,6 +1031,28 @@ namespace jdi void PaintCenterCurve(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane, int smooth) const; + /** + * @brief 绘制某条信号的均值滤波曲线(与中心平均值曲线同一算法与同一配色派生规则)。 + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] resolved 信号视图。 + * @param [IN] lane 网格绘制信息。 + * @param [IN] radius 均值滤波半径 N。 + */ + void PaintMeanFilterCurve(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane, + int radius) const; + + /** + * @brief 取某个时刻的均值滤波曲线取值(用于纵向光标的取值标注)。 + * @param [IN] file 信号所属文件。 + * @param [IN] signal 目标信号。 + * @param [IN] radius 均值滤波半径 N。 + * @param [IN] time 采样时刻。 + * @param [OUT] value 输出取值。 + * @return 取到有效值返回 true。 + */ + bool MeanFilterValueAt(const DataFile& file, const SignalData& signal, int radius, double time, + double& value) const; + /** * @brief 数据点标记的位置与"越界方向"(用于决定画圆点还是贴边三角)。 */ @@ -1066,10 +1135,10 @@ namespace jdi /** * @brief 计算实验性功能面板所需的宽度(排版与绘制共用,保证文字完整不被省略)。 * @param [IN] laneIndex 网格下标。 - * @param [IN] centerAxis 为 true 时取「边沿计数(中心轴)」面板。 + * @param [IN] kind 面板种类。 * @return 面板宽度(像素)。 */ - float EdgePanelWidth(int laneIndex, bool centerAxis) const; + float EdgePanelWidth(int laneIndex, EdgePanelKind kind) const; /** * @brief 判断边沿统计是否限定在双光标区间内(双光标模式)。 @@ -1205,6 +1274,9 @@ namespace jdi std::wstring m_pendingRenameName; /**< 待宿主窗口应用的信号新名称。 */ bool m_pendingRename = false; /**< 是否存在待应用的重命名请求。 */ + int m_pendingMeanFilterLane = -1; /**< 待构建的均值滤波信号所属网格下标(-1 表示无请求)。 */ + int m_pendingMeanFilterRadius = 0; /**< 待构建的均值滤波半径 N。 */ + int m_hoverLane = -1; /**< 当前悬停网格下标。 */ int m_mouseX = 0; /**< 最近一次鼠标横坐标(用于悬停提示定位)。 */ int m_mouseY = 0; /**< 最近一次鼠标纵坐标(用于悬停提示定位)。 */ @@ -1257,7 +1329,7 @@ namespace jdi // 「边沿计数」横向光标 / 面板的拖拽状态。 int m_edgeDragLane = -1; /**< 正在拖动的横向光标所属网格。 */ int m_edgePanelDragLane = -1; /**< 正在拖动的统计面板所属网格。 */ - bool m_edgePanelDragCenter = false; /**< 正在拖动的面板是否为「中心轴边沿计数」面板。 */ + EdgePanelKind m_edgePanelDragKind = EdgePanelKind::COUNT; /**< 正在拖动的面板种类。 */ gd::PointF m_edgePanelDragOffset; /**< 抓取面板时的相对偏移(保持不跳变)。 */ /** diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index 16acaa8..e0d5e32 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -55,9 +55,19 @@ namespace jdi const float kEdgePanelEditHeight = 30.0f; /**< 中心轴面板底部编辑行高度。 */ const float kEdgePanelEditFieldWidth = 64.0f; /**< 编辑行数值框的最小宽度。 */ const float kEdgePanelEditFieldGap = 8.0f; /**< 编辑行说明文字与数值框之间的间隔。 */ + const float kEdgePanelButtonHeight = 30.0f; /**< 均值滤波面板底部按钮行高度。 */ + const wchar_t* const kMeanFilterBuildLabel = L"构建为新信号"; /**< 均值滤波面板底部按钮文案。 */ const std::uint64_t kHintHoldMs = 1300; /**< 提示浮层完全显示的停留时长(毫秒)。 */ const std::uint64_t kHintFadeMs = 500; /**< 提示浮层的淡出时长(毫秒)。 */ + /** + * @brief 实验性功能浮动面板的遍历顺序(命中测试与绘制共用,保证层序一致)。 + * + * 顺序即绘制层序:先绘制的在下方,最后绘制的在最上方。 + */ + const EdgePanelKind kEdgePanelKinds[] = { + EdgePanelKind::COUNT, EdgePanelKind::CENTER, EdgePanelKind::MEAN}; + /** * @brief 时间间隔的显示单位。 */ @@ -949,6 +959,11 @@ namespace jdi m_edgeCountCache.clear(); m_edgeCenterCountCache.clear(); m_centerCurveCache.clear(); + // 信号下标可能已经整体位移:清掉按引用缓存的悬停/拖拽目标,避免指向错误的信号。 + m_hoverSignal = SignalRef(); + m_hoverLegend = SignalRef(); + m_dragSignal = SignalRef(); + m_dragTargetLane = -1; InvalidateSceneCache(); } @@ -1458,6 +1473,62 @@ namespace jdi return enabled; } + bool PlotView::LaneMeanFilter(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)].meanFilter; + } + + bool PlotView::SetLaneMeanFilter(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.meanFilter == enabled) + { + return false; + } + lane.meanFilter = enabled; + if (enabled) + { + if (lane.meanFilterRadius < 0) + { + lane.meanFilterRadius = 0; + } + lane.meanFilterPanelOpen = true; + } + else + { + lane.meanFilterPanelOpen = false; + } + return true; + } + + bool PlotView::ToggleLaneMeanFilter(int laneIndex) + { + const bool enabled = !LaneMeanFilter(laneIndex); + SetLaneMeanFilter(laneIndex, enabled); + return enabled; + } + + bool PlotView::TakePendingMeanFilterBuild(int& laneIndex, int& radius) + { + if (m_pendingMeanFilterLane < 0) + { + return false; + } + laneIndex = m_pendingMeanFilterLane; + radius = m_pendingMeanFilterRadius; + m_pendingMeanFilterLane = -1; + m_pendingMeanFilterRadius = 0; + return true; + } + const std::vector& PlotView::CenterCurveFor(const SignalData& signal, int smooth) const { const int radius = std::max(0, smooth); @@ -1472,74 +1543,9 @@ namespace jdi CenterCurveEntry entry; entry.signal = &signal; entry.smooth = radius; - entry.values.assign(signal.values.size(), std::numeric_limits::quiet_NaN()); - - const std::size_t count = signal.values.size(); - if (count > 0) - { - // 滑动窗口求和:窗口 = [max(0,i-N), min(count-1,i+N)], - // 每前进一步只增减边界处的两个采样点,整体 O(n),不额外占用前缀和内存。 - double sum = 0.0; - std::size_t finiteCount = 0; - std::size_t windowLow = 0; - std::size_t windowHigh = 0; // 闭区间右端(尚未计入时为 0)。 - - const auto addValue = [&sum, &finiteCount](double value) - { - if (std::isfinite(value)) - { - sum += value; - ++finiteCount; - } - }; - const auto removeValue = [&sum, &finiteCount](double value) - { - if (std::isfinite(value)) - { - sum -= value; - --finiteCount; - } - }; - - for (std::size_t index = 0; index < count; ++index) - { - const std::size_t low = (index > static_cast(radius)) - ? (index - static_cast(radius)) - : 0; - const std::size_t high = std::min(count - 1, index + static_cast(radius)); - - if (index == 0) - { - windowLow = low; - windowHigh = high; - for (std::size_t i = windowLow; i <= windowHigh; ++i) - { - addValue(signal.values[i]); - } - } - else - { - // 左端前移:移出旧左端(若已不在窗口内)。 - while (windowLow < low) - { - removeValue(signal.values[windowLow]); - ++windowLow; - } - // 右端前移:加入新右端。 - while (windowHigh < high) - { - ++windowHigh; - addValue(signal.values[windowHigh]); - } - } - - // 中心点本身无效(NaN)时该处不绘制曲线。 - if (finiteCount > 0 && std::isfinite(signal.values[index])) - { - entry.values[index] = static_cast(sum / static_cast(finiteCount)); - } - } - } + // 中心平均值曲线与「均值滤波」共用同一实现(DataSet 内的自由函数), + // 因此预览曲线与"构建为新信号"得到的采样值逐位一致。 + ComputeMovingAverage(signal.values, radius, entry.values); m_centerCurveCache.push_back(std::move(entry)); return m_centerCurveCache.back().values; @@ -1567,6 +1573,13 @@ namespace jdi return true; } + bool PlotView::MeanFilterValueAt(const DataFile& file, const SignalData& signal, int radius, + double time, double& value) const + { + // 均值滤波曲线与中心平均值曲线是同一算法,取值路径完全一致。 + return CenterValueAt(file, signal, radius, time, value); + } + void PlotView::EdgeCenterCountsFor(const DataFile& file, const SignalData& signal, int smooth, bool hasRange, double rangeMin, double rangeMax, std::size_t& rising, std::size_t& falling) const @@ -1760,7 +1773,7 @@ namespace jdi m_edgeCountCache.push_back(entry); } - float PlotView::EdgePanelWidth(int laneIndex, bool centerAxis) const + float PlotView::EdgePanelWidth(int laneIndex, EdgePanelKind kind) const { if (m_tab == nullptr || m_fonts == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) @@ -1768,9 +1781,13 @@ namespace jdi return kEdgePanelWidth * m_scale; } const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; + const bool centerAxis = (kind == EdgePanelKind::CENTER); + const bool meanFilter = (kind == EdgePanelKind::MEAN); - // 标题行 = “GX” 徽标 + 标题(“边沿计数” / “中心轴边沿计数” + 光标区间尾缀)。 - const std::wstring title = (centerAxis ? L"中心轴边沿计数" : L"边沿计数") + EdgeRangeSuffix(); + // 标题行 = “GX” 徽标 + 标题(“边沿计数” / “中心轴边沿计数” / “均值滤波” + 光标区间尾缀)。 + const std::wstring title = + meanFilter ? std::wstring(L"均值滤波") + : ((centerAxis ? L"中心轴边沿计数" : L"边沿计数") + EdgeRangeSuffix()); float width = kEdgePanelPad * 2.0f * m_scale + kLaneBadgeWidth * m_scale + 8.0f * m_scale + UiMeasureTextWidth(m_fonts->uiSmall, title) + kEdgePanelTitleHeight * m_scale; @@ -1785,6 +1802,11 @@ namespace jdi continue; } const float nameWidth = UiMeasureTextWidth(m_fonts->uiSmall, resolved.data->Title()); + if (meanFilter) + { + width = std::max(width, kEdgePanelPad * 2.0f * m_scale + nameWidth); + continue; + } std::size_t rising = 0; std::size_t falling = 0; if (centerAxis) @@ -1804,17 +1826,26 @@ namespace jdi width = std::max(width, kEdgePanelPad * 2.0f * m_scale + std::max(nameWidth, countWidth)); } - if (centerAxis) + if (centerAxis || meanFilter) { - // 底部编辑行 = “平滑点数”说明文字 + 数值框(与菜单内联编辑同一套排版习惯)。 + // 底部编辑行 = “平滑点数 / 均值点数”说明文字 + 数值框(与菜单内联编辑同一套排版习惯)。 + const int shown = meanFilter ? lane.meanFilterRadius : lane.edgeCenterSmooth; + const std::wstring label = meanFilter ? L"均值点数 N" : L"平滑点数 N"; const float editWidth = - UiMeasureTextWidth(m_fonts->uiSmall, L"平滑点数 N") + kEdgePanelEditFieldGap * m_scale + + UiMeasureTextWidth(m_fonts->uiSmall, label) + kEdgePanelEditFieldGap * m_scale + std::max(kEdgePanelEditFieldWidth * m_scale, - UiMeasureTextWidth(m_fonts->monoSmall, std::to_wstring(lane.edgeCenterSmooth)) + - 20.0f * m_scale); + UiMeasureTextWidth(m_fonts->monoSmall, std::to_wstring(shown)) + 20.0f * m_scale); width = std::max(width, kEdgePanelPad * 2.0f * m_scale + editWidth); } + if (meanFilter) + { + // 底部按钮行 = 「构建为新信号」,按按钮文字实测宽度确定下限。 + const float buttonWidth = + UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterBuildLabel) + 28.0f * m_scale; + width = std::max(width, kEdgePanelPad * 2.0f * m_scale + buttonWidth); + } + // 夹在绘图区宽度之内(过窄的网格也不至于把面板挤到外面)。 return std::min(width, std::max(120.0f * m_scale, m_plotRect.Width - 8.0f * m_scale)); } @@ -2383,9 +2414,10 @@ namespace jdi return gd::RectF(m_editFieldRect.X, m_editFieldRect.Y, nameWidth, height); } - if (m_editTarget == PlotEditTarget::EDGE_CENTER_SMOOTH) + if (m_editTarget == PlotEditTarget::EDGE_CENTER_SMOOTH || + m_editTarget == PlotEditTarget::MEAN_FILTER_RADIUS) { - // 「平滑点数 N」:输入框落在中心轴面板底部的编辑行内(靠右,与显示态对齐)。 + // 「平滑点数 / 均值点数 N」:输入框落在面板底部的编辑行内(靠右,与显示态对齐)。 if (m_editFieldRect.Width <= 0.0f) { return gd::RectF(); @@ -2565,6 +2597,18 @@ namespace jdi return true; } + // 「均值点数 N」:只作用于均值滤波曲线的计算,夹在合理范围内(0–1000000)。 + if (target == PlotEditTarget::MEAN_FILTER_RADIUS) + { + int radius = static_cast(std::lround(parsed)); + radius = std::max(0, std::min(radius, 1000000)); + m_tab->Lanes()[static_cast(laneIndex)].meanFilterRadius = radius; + // 均值半径变化会改变曲线:清掉派生缓存后重绘(面板宽度也随之变化)。 + m_centerCurveCache.clear(); + InvalidateSceneCache(); + return true; + } + // 直接输入上下限:先固化当前范围,再用新值替换对应端点(保证 max > min)。 if (laneIndex >= static_cast(m_lanes.size())) { @@ -2781,12 +2825,11 @@ namespace jdi // 面板**只有标题栏**可以拖拽(显示抓握光标);✕ 与面板主体一律保持原始箭头。 if (m_tab != nullptr) { - for (int pass = 0; pass < 2; ++pass) + for (const EdgePanelKind kind : kEdgePanelKinds) { - const bool centerAxis = (pass == 1); for (int index = 0; index < static_cast(m_lanes.size()); ++index) { - const EdgePanelLayout layout = EdgePanelLayoutFor(index, centerAxis); + const EdgePanelLayout layout = EdgePanelLayoutFor(index, kind); if (layout.panel.Width <= 0.0f || !UiPointInRect(layout.panel, static_cast(x), static_cast(y))) { @@ -2801,7 +2844,13 @@ namespace jdi { return LoadCursorW(nullptr, IDC_SIZEALL); } - // 面板主体(含底部编辑行):保持原始箭头。 + if (kind == EdgePanelKind::MEAN && layout.button.Width > 0.0f && + UiPointInRect(layout.button, static_cast(x), static_cast(y))) + { + // 「构建为新信号」按钮:手型光标作为可点击提示。 + return LoadCursorW(nullptr, IDC_HAND); + } + // 面板主体(含底部编辑行与按钮):保持原始箭头。 return nullptr; } } @@ -3029,7 +3078,7 @@ namespace jdi break; } const gd::RectF panel = - EdgePanelLayoutFor(m_edgePanelDragLane, m_edgePanelDragCenter).panel; + EdgePanelLayoutFor(m_edgePanelDragLane, m_edgePanelDragKind).panel; if (panel.Width <= 0.0f) { break; @@ -3041,15 +3090,21 @@ namespace jdi top = std::max(m_plotRect.Y + 2.0f * m_scale, std::min(top, m_plotRect.Y + m_plotRect.Height - panel.Height - 2.0f * m_scale)); Lane& lane = m_tab->Lanes()[static_cast(m_edgePanelDragLane)]; - if (m_edgePanelDragCenter) + switch (m_edgePanelDragKind) { + case EdgePanelKind::CENTER: lane.edgeCenterPanelX = left; lane.edgeCenterPanelY = top; - } - else - { + break; + case EdgePanelKind::MEAN: + lane.meanFilterPanelX = left; + lane.meanFilterPanelY = top; + break; + case EdgePanelKind::COUNT: + default: lane.edgePanelX = left; lane.edgePanelY = top; + break; } m_dragMoved = true; return true; @@ -3065,12 +3120,14 @@ namespace jdi // 但该面板所属的网格仍然要高亮(与鼠标直接停在网格上一致)。 bool overEdgePanel = false; int panelLane = -1; - for (int pass = 0; pass < 2 && !overEdgePanel; ++pass) + for (long kindIndex = 0; + kindIndex < static_cast(sizeof(kEdgePanelKinds) / sizeof(kEdgePanelKinds[0])) && + !overEdgePanel; + ++kindIndex) { - const bool centerAxis = (pass == 1); for (int index = 0; index < static_cast(m_lanes.size()); ++index) { - const gd::RectF panel = EdgePanelLayoutFor(index, centerAxis).panel; + const gd::RectF panel = EdgePanelLayoutFor(index, kEdgePanelKinds[kindIndex]).panel; if (panel.Width > 0.0f && UiPointInRect(panel, static_cast(x), static_cast(y))) { @@ -3223,16 +3280,17 @@ namespace jdi // 实验性功能浮动面板(面板压在波形之上,必须先于信号拖拽判定): // * ✕ → 关闭该网格对应的功能; - // * 底部编辑行 → 进入「平滑点数 N」内联编辑; + // * 底部编辑行 → 进入「平滑点数 / 均值点数 N」内联编辑; + // * 底部按钮 → 触发「构建为新信号」请求; // * 标题栏 → 拖动面板; // * 面板主体 → 消费点击,但**不拖动也不穿透**(用户明确要求只有标题栏可拖拽)。 - for (int pass = 0; pass < 2; ++pass) + for (int pass = 0; pass < 3; ++pass) { - const bool centerAxis = (pass == 1); + const EdgePanelKind kind = kEdgePanelKinds[pass]; bool handledPanel = false; for (int index = 0; index < static_cast(m_lanes.size()); ++index) { - const EdgePanelLayout layout = EdgePanelLayoutFor(index, centerAxis); + const EdgePanelLayout layout = EdgePanelLayoutFor(index, kind); if (layout.panel.Width <= 0.0f || !UiPointInRect(layout.panel, fx, fy)) { @@ -3243,25 +3301,52 @@ namespace jdi if (UiPointInRect(layout.close, fx, fy)) { // ✕ 关闭面板的同时关闭该网格对应的实验性功能。 - if (centerAxis) + switch (kind) { + case EdgePanelKind::CENTER: SetLaneEdgeCenterCount(index, false); - } - else - { + break; + case EdgePanelKind::MEAN: + SetLaneMeanFilter(index, false); + break; + case EdgePanelKind::COUNT: + default: SetLaneEdgeCount(index, false); + break; } m_dragMode = PlotDragMode::NONE; return true; } - if (centerAxis && layout.edit.Width > 0.0f && UiPointInRect(layout.edit, fx, fy) && - m_tab != nullptr) + if ((kind == EdgePanelKind::CENTER || kind == EdgePanelKind::MEAN) && + layout.edit.Width > 0.0f && UiPointInRect(layout.edit, fx, fy) && m_tab != nullptr) { - // 底部编辑行 → 就地编辑平滑点数 N(输入框落在该行内)。 - const int smooth = m_tab->Lanes()[static_cast(index)].edgeCenterSmooth; - BeginEdit(PlotEditTarget::EDGE_CENTER_SMOOTH, index, std::to_wstring(smooth), - layout.edit); + // 底部编辑行 → 就地编辑参数 N(输入框落在该行内)。 + const Lane& tabLane = m_tab->Lanes()[static_cast(index)]; + if (kind == EdgePanelKind::MEAN) + { + BeginEdit(PlotEditTarget::MEAN_FILTER_RADIUS, index, + std::to_wstring(tabLane.meanFilterRadius), layout.edit); + } + else + { + BeginEdit(PlotEditTarget::EDGE_CENTER_SMOOTH, index, + std::to_wstring(tabLane.edgeCenterSmooth), layout.edit); + } + return true; + } + + if (kind == EdgePanelKind::MEAN && layout.button.Width > 0.0f && + UiPointInRect(layout.button, fx, fy) && m_tab != nullptr) + { + // 「构建为新信号」:只登记请求,真正的信号构建由宿主(Application)完成。 + SetHint(L"正在构建均值滤波信号(" + + std::to_wstring(m_tab->Lanes()[static_cast(index)].meanFilterRadius) + + L" 点均值)…"); + m_pendingMeanFilterLane = index; + m_pendingMeanFilterRadius = + m_tab->Lanes()[static_cast(index)].meanFilterRadius; + m_dragMode = PlotDragMode::NONE; return true; } @@ -3269,7 +3354,7 @@ namespace jdi { m_dragMode = PlotDragMode::EDGE_PANEL_MOVE; m_edgePanelDragLane = index; - m_edgePanelDragCenter = centerAxis; + m_edgePanelDragKind = kind; m_edgePanelDragOffset.X = fx - layout.panel.X; m_edgePanelDragOffset.Y = fy - layout.panel.Y; return true; @@ -3363,7 +3448,7 @@ namespace jdi m_cursorGrabOffset = 0; m_edgeDragLane = -1; m_edgePanelDragLane = -1; - m_edgePanelDragCenter = false; + m_edgePanelDragKind = EdgePanelKind::COUNT; SetHint(std::wstring()); m_dragLane = LaneAtY(y); m_dragMode = PlotDragMode::PAN; @@ -3440,7 +3525,7 @@ namespace jdi m_cursorGrabOffset = 0; m_edgeDragLane = -1; m_edgePanelDragLane = -1; - m_edgePanelDragCenter = false; + m_edgePanelDragKind = EdgePanelKind::COUNT; // 松开左键:粘滞提示结束计时,开始"停留后淡出"。 ReleaseHint(); (void)x; @@ -4253,6 +4338,9 @@ namespace jdi // 「边沿计数(中心轴)」的开关与平滑点数会改变网格内的曲线绘制 → 参与指纹。 mix(Tab().Lanes()[laneIndex].edgeCenter ? 1ull : 0ull); mix(static_cast(Tab().Lanes()[laneIndex].edgeCenterSmooth)); + // 「均值滤波」的开关与均值点数同样会改变网格内的曲线绘制 → 参与指纹。 + mix(Tab().Lanes()[laneIndex].meanFilter ? 1ull : 0ull); + mix(static_cast(Tab().Lanes()[laneIndex].meanFilterRadius)); mix(signals.size()); for (const SignalRef& ref : signals) { @@ -4610,6 +4698,11 @@ namespace jdi Tab().Lanes()[static_cast(laneIndex)].edgeCenter ? Tab().Lanes()[static_cast(laneIndex)].edgeCenterSmooth : -1; + // 「均值滤波」开启时同样叠加一条 N 点滑动平均曲线(与中心平均值曲线同一算法)。 + const int meanFilterRadius = + Tab().Lanes()[static_cast(laneIndex)].meanFilter + ? Tab().Lanes()[static_cast(laneIndex)].meanFilterRadius + : -1; for (const SignalRef& ref : laneSignals) { @@ -4729,6 +4822,11 @@ namespace jdi { PaintCenterCurve(g, resolved, lane, centerSmooth); } + // 均值滤波曲线(「实验性功能 → 均值滤波」):同样是仅用于预览的叠加曲线。 + if (meanFilterRadius >= 0) + { + PaintMeanFilterCurve(g, resolved, lane, meanFilterRadius); + } } // g.Restore 也会还原绘制状态,此处直接恢复图形状态即可。 @@ -5306,68 +5404,87 @@ namespace jdi UiText(g, text, m_fonts->monoSmall, labelRect, dark ? 0xFFFDF6E3u : 0xFF073642u, TextAlign::CENTER); - // 「边沿计数(中心轴)」开启时,纵向光标还要额外标注**中心平均值曲线** + // 「边沿计数(中心轴)」/「均值滤波」开启时,纵向光标还要额外标注**派生曲线** // 在该时刻的取值(它不是信号,因此单独标注,不并入上面的取值文案)。 - if (m_tab->Lanes()[laneIndex].edgeCenter) + // 两条曲线的标注规则完全一致,故共用同一段绘制逻辑。 + const auto paintDerivedCapsule = [&](int smooth, const std::wstring& suffix, + bool meanFilter) { - const int smooth = m_tab->Lanes()[laneIndex].edgeCenterSmooth; - double centerValue = 0.0; - if (CenterValueAt(*resolved.file, *resolved.data, smooth, time, centerValue)) + double derivedValue = 0.0; + const bool ok = meanFilter + ? MeanFilterValueAt(*resolved.file, *resolved.data, smooth, + time, derivedValue) + : CenterValueAt(*resolved.file, *resolved.data, smooth, time, + derivedValue); + if (!ok) { - const float rawCenterY = ValueToY(centerValue, lane); - const DataMarker centerMarker = MakeDataMarker( - rawX, rawCenterY, pinX, clipTop, clipBottom, kCursorHandleReserve * m_scale); + return; + } + + const float rawCenterY = ValueToY(derivedValue, lane); + const DataMarker centerMarker = MakeDataMarker( + rawX, rawCenterY, pinX, clipTop, clipBottom, kCursorHandleReserve * m_scale); + // 派生数据点的配色与原始信号一致(便于一一对应); + // 但**始终按线段模式**绘制(不跟随原信号的散点模式)。 + const std::uint32_t centerColor = StyleColor(*resolved.style); + PaintDataMarker(g, centerMarker, radius, centerColor, m_theme->cardBg); + + // 取值文本:数值在前,尾缀「(中心轴)/(均值滤波)」;双光标的右侧光标 + // 还要附上增量,形如「(+0.0008 中心轴)」(参考常规信号的增量显示)。 + std::wstring centerText = FormatValue(derivedValue); + centerText += L"("; + if (showDelta) + { + double otherDerived = 0.0; + const bool otherOk = + meanFilter + ? MeanFilterValueAt(*resolved.file, *resolved.data, smooth, + otherTime, otherDerived) + : CenterValueAt(*resolved.file, *resolved.data, smooth, otherTime, + otherDerived); + if (otherOk) { - // 中心轴数据点的配色与原始信号一致(便于一一对应); - // 但**始终按线段模式**绘制(不跟随原信号的散点模式)。 - const std::uint32_t centerColor = StyleColor(*resolved.style); - PaintDataMarker(g, centerMarker, radius, centerColor, m_theme->cardBg); - - // 取值文本:数值在前,尾缀「(中心轴)」;双光标的右侧光标还要 - // 附上增量,形如「(+0.0008 中心轴)」(参考常规信号的增量显示)。 - std::wstring centerText = FormatValue(centerValue); - centerText += L"("; - if (showDelta) - { - double otherCenter = 0.0; - if (CenterValueAt(*resolved.file, *resolved.data, smooth, otherTime, - otherCenter)) - { - const double delta = centerValue - otherCenter; - centerText += (delta < 0.0) ? L"-" : L"+"; - centerText += FormatValue(std::fabs(delta)); - centerText += L" "; - } - } - centerText += L"中心轴)"; - - const float centerTextWidth = - UiTextWidth(g, centerText, m_fonts->monoSmall) + 10.0f * m_scale; - float centerLeft = pinX + radius + 4.0f * m_scale; - if (centerLeft + centerTextWidth > - m_plotRect.X + m_plotRect.Width - 2.0f * m_scale) - { - centerLeft = pinX - radius - 4.0f * m_scale - centerTextWidth; - } - centerLeft = std::max( - m_plotRect.X + 2.0f * m_scale, - std::min(centerLeft, m_plotRect.X + m_plotRect.Width - - centerTextWidth - 2.0f * m_scale)); - float centerTop = centerMarker.y - textHeight * 0.5f; - centerTop = std::max( - clipTop + 1.0f * m_scale, - std::min(centerTop, clipBottom - textHeight - 1.0f * m_scale)); - gd::RectF centerRect(centerLeft, centerTop, centerTextWidth, textHeight); - PlaceCursorLabel(centerRect, centerTop, textHeight, clipTop, clipBottom); - // 颜色样式与原始信号一致(同底色/同描边/同文字配色)。 - const bool centerDark = IsDarkColor(centerColor); - UiFillRoundRect(g, centerRect, 3.0f * m_scale, - WithAlpha(centerColor, 0xE0)); - DrawLabelOutline(g, centerRect, 3.0f * m_scale, centerDark); - UiText(g, centerText, m_fonts->monoSmall, centerRect, - centerDark ? 0xFFFDF6E3u : 0xFF073642u, TextAlign::CENTER); + const double delta = derivedValue - otherDerived; + centerText += (delta < 0.0) ? L"-" : L"+"; + centerText += FormatValue(std::fabs(delta)); + centerText += L" "; } } + centerText += suffix; + centerText += L")"; + + const float centerTextWidth = + UiTextWidth(g, centerText, m_fonts->monoSmall) + 10.0f * m_scale; + float centerLeft = pinX + radius + 4.0f * m_scale; + if (centerLeft + centerTextWidth > + m_plotRect.X + m_plotRect.Width - 2.0f * m_scale) + { + centerLeft = pinX - radius - 4.0f * m_scale - centerTextWidth; + } + centerLeft = std::max( + m_plotRect.X + 2.0f * m_scale, + std::min(centerLeft, + m_plotRect.X + m_plotRect.Width - centerTextWidth - 2.0f * m_scale)); + float centerTop = centerMarker.y - textHeight * 0.5f; + centerTop = std::max(clipTop + 1.0f * m_scale, + std::min(centerTop, clipBottom - textHeight - 1.0f * m_scale)); + gd::RectF centerRect(centerLeft, centerTop, centerTextWidth, textHeight); + PlaceCursorLabel(centerRect, centerTop, textHeight, clipTop, clipBottom); + // 颜色样式与原始信号一致(同底色/同描边/同文字配色)。 + const bool centerDark = IsDarkColor(centerColor); + UiFillRoundRect(g, centerRect, 3.0f * m_scale, WithAlpha(centerColor, 0xE0)); + DrawLabelOutline(g, centerRect, 3.0f * m_scale, centerDark); + UiText(g, centerText, m_fonts->monoSmall, centerRect, + centerDark ? 0xFFFDF6E3u : 0xFF073642u, TextAlign::CENTER); + }; + + if (m_tab->Lanes()[laneIndex].edgeCenter) + { + paintDerivedCapsule(m_tab->Lanes()[laneIndex].edgeCenterSmooth, L"中心轴", false); + } + if (m_tab->Lanes()[laneIndex].meanFilter) + { + paintDerivedCapsule(m_tab->Lanes()[laneIndex].meanFilterRadius, L"均值滤波", true); } } } @@ -5424,7 +5541,7 @@ namespace jdi } } - PlotView::EdgePanelLayout PlotView::EdgePanelLayoutFor(int laneIndex, bool centerAxis) const + PlotView::EdgePanelLayout PlotView::EdgePanelLayoutFor(int laneIndex, EdgePanelKind kind) const { EdgePanelLayout layout; if (m_tab == nullptr || m_fonts == nullptr || laneIndex < 0 || @@ -5434,8 +5551,11 @@ namespace jdi return layout; } const Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; - const bool enabled = centerAxis ? lane.edgeCenter : lane.edgeCount; - const bool open = centerAxis ? lane.edgeCenterPanelOpen : lane.edgePanelOpen; + const bool centerAxis = (kind == EdgePanelKind::CENTER); + const bool meanFilter = (kind == EdgePanelKind::MEAN); + const bool enabled = meanFilter ? lane.meanFilter : (centerAxis ? lane.edgeCenter : lane.edgeCount); + const bool open = meanFilter ? lane.meanFilterPanelOpen + : (centerAxis ? lane.edgeCenterPanelOpen : lane.edgePanelOpen); if (!enabled || !open) { return layout; @@ -5456,18 +5576,23 @@ namespace jdi } layout.rows = rowCount; - const float width = EdgePanelWidth(laneIndex, centerAxis); + const float width = EdgePanelWidth(laneIndex, kind); const float titleHeight = kEdgePanelTitleHeight * m_scale; const float rowHeight = kEdgePanelRowHeight * m_scale; const float pad = kEdgePanelPad * m_scale; - // 中心轴面板在底部固定多出一行「平滑点数」编辑行。 - const float editHeight = centerAxis ? (kEdgePanelEditHeight * m_scale) : 0.0f; - const float height = titleHeight + pad + static_cast(rowCount) * rowHeight * 2.0f + pad + - editHeight; + // 中心轴 / 均值滤波面板在底部固定多出一行编辑行;均值滤波再多一个按钮行。 + const float editHeight = + (centerAxis || meanFilter) ? (kEdgePanelEditHeight * m_scale) : 0.0f; + const float buttonHeight = meanFilter ? (kEdgePanelButtonHeight * m_scale) : 0.0f; + const float rowsPerSignal = meanFilter ? 1.0f : 2.0f; + const float height = titleHeight + pad + static_cast(rowCount) * rowHeight * rowsPerSignal + + pad + editHeight + buttonHeight; // 首次使用(位置为负)时自动放到绘图区右侧,并各网格依次错开,避免完全重叠。 - const double storedX = centerAxis ? lane.edgeCenterPanelX : lane.edgePanelX; - const double storedY = centerAxis ? lane.edgeCenterPanelY : lane.edgePanelY; + const double storedX = meanFilter ? lane.meanFilterPanelX + : (centerAxis ? lane.edgeCenterPanelX : lane.edgePanelX); + const double storedY = meanFilter ? lane.meanFilterPanelY + : (centerAxis ? lane.edgeCenterPanelY : lane.edgePanelY); float left = (storedX >= 0.0) ? static_cast(storedX) : (m_plotRect.X + m_plotRect.Width - width - 16.0f * m_scale); @@ -5487,28 +5612,35 @@ namespace jdi layout.title = gd::RectF(left, top, width, titleHeight); const float closeSize = titleHeight; layout.close = gd::RectF(left + width - closeSize, top, closeSize, closeSize); - if (centerAxis) + if (centerAxis || meanFilter) { const float editRowHeight = kEdgePanelEditHeight * m_scale; - layout.edit = gd::RectF(left + pad, top + height - editRowHeight, - std::max(0.0f, width - pad * 2.0f), editRowHeight); + const float editTop = top + height - buttonHeight - editRowHeight; + layout.edit = gd::RectF(left + pad, editTop, std::max(0.0f, width - pad * 2.0f), + editRowHeight); + } + if (meanFilter) + { + const float buttonRowHeight = kEdgePanelButtonHeight * m_scale; + layout.button = gd::RectF(left + pad, top + height - buttonRowHeight, + std::max(0.0f, width - pad * 2.0f), buttonRowHeight); } return layout; } gd::RectF PlotView::EdgePanelRect(int laneIndex) const { - return EdgePanelLayoutFor(laneIndex, false).panel; + return EdgePanelLayoutFor(laneIndex, EdgePanelKind::COUNT).panel; } gd::RectF PlotView::EdgePanelCloseRect(int laneIndex) const { - return EdgePanelLayoutFor(laneIndex, false).close; + return EdgePanelLayoutFor(laneIndex, EdgePanelKind::COUNT).close; } gd::RectF PlotView::EdgeCenterPanelRect(int laneIndex) const { - return EdgePanelLayoutFor(laneIndex, true).panel; + return EdgePanelLayoutFor(laneIndex, EdgePanelKind::CENTER).panel; } bool PlotView::IsOnEdgeCursorLine(int x, int y, int& laneIndex) const @@ -5785,6 +5917,14 @@ namespace jdi } } + void PlotView::PaintMeanFilterCurve(gd::Graphics& g, const ResolvedSignal& resolved, + const LaneVisual& lane, int radius) const + { + // 均值滤波曲线与「边沿计数(中心轴)」的中心平均值曲线是**同一算法、同一视觉效果** + // (同色相高对比度的派生配色 + 同透明度),因此直接复用同一段绘制逻辑。 + PaintCenterCurve(g, resolved, lane, radius); + } + void PlotView::PaintEdgeCursor(gd::Graphics& g, const std::vector& lanes) { if (m_tab == nullptr || m_dataSet == nullptr) @@ -5858,14 +5998,17 @@ namespace jdi } // ---- 统计浮动面板(最后绘制,覆盖在波形之上)---- - // 「边沿计数」与「边沿计数(中心轴)」共用同一套样式,仅标题、统计口径与底部编辑行不同。 - for (int pass = 0; pass < 2; ++pass) + // 「边沿计数」「边沿计数(中心轴)」「均值滤波」共用同一套样式, + // 仅标题、内容行与底部编辑行/按钮不同。 + for (int pass = 0; pass < 3; ++pass) { - const bool centerAxis = (pass == 1); + const EdgePanelKind kind = kEdgePanelKinds[pass]; + const bool centerAxis = (kind == EdgePanelKind::CENTER); + const bool meanFilter = (kind == EdgePanelKind::MEAN); for (std::size_t index = 0; index < count; ++index) { const Lane& tabLane = tabLanes[index]; - const EdgePanelLayout layout = EdgePanelLayoutFor(static_cast(index), centerAxis); + const EdgePanelLayout layout = EdgePanelLayoutFor(static_cast(index), kind); const gd::RectF& panel = layout.panel; if (panel.Width <= 0.0f || panel.Height <= 0.0f) { @@ -5895,7 +6038,8 @@ namespace jdi UiText(g, L"G" + std::to_wstring(index + 1), m_fonts->monoSmall, badgeRect, m_theme->text, TextAlign::CENTER); const std::wstring title = - (centerAxis ? L"中心轴边沿计数" : L"边沿计数") + EdgeRangeSuffix(); + meanFilter ? std::wstring(L"均值滤波") + : ((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, @@ -5923,7 +6067,8 @@ namespace jdi closeRect.X + crossPad, closeRect.Y + closeRect.Height - crossPad, crossColor, 1.6f * m_scale); - // 每个信号两行:名称(信号色)+ 上升沿/下降沿数量。 + // 每个信号两行:名称(信号色)+ 上升沿/下降沿数量; + // 均值滤波面板只需列出被处理的信号名(一行)。 float rowY = panel.Y + titleHeight + pad; for (const SignalRef& ref : tabLane.signals) { @@ -5938,6 +6083,11 @@ namespace jdi UiText(g, resolved.data->Title(), m_fonts->uiSmall, gd::RectF(panel.X + pad, rowY, textWidth, rowHeight), signalColor, TextAlign::LEFT); + if (meanFilter) + { + rowY += rowHeight; + continue; + } std::size_t rising = 0; std::size_t falling = 0; @@ -5960,32 +6110,33 @@ namespace jdi rowY += rowHeight * 2.0f; } - // 中心轴面板底部固定一行「平滑点数」编辑设置(始终位于面板最下方)。 - if (centerAxis) + // 中心轴 / 均值滤波面板底部固定一行参数编辑设置(始终位于面板最下方)。 + if (centerAxis || meanFilter) { const gd::RectF& editRect = layout.edit; if (editRect.Width > 0.0f) { - const float fieldGap = kEdgePanelEditFieldGap * m_scale; - const std::wstring label = L"平滑点数 N"; + const int shownValue = meanFilter ? tabLane.meanFilterRadius + : tabLane.edgeCenterSmooth; + const std::wstring label = meanFilter ? L"均值点数 N" : L"平滑点数 N"; const float labelWidth = UiMeasureTextWidth(m_fonts->uiSmall, label); UiText(g, label, m_fonts->uiSmall, gd::RectF(editRect.X, editRect.Y, labelWidth, editRect.Height), m_theme->text, TextAlign::LEFT); - const float fieldWidth = std::max( - kEdgePanelEditFieldWidth * m_scale, - UiMeasureTextWidth(m_fonts->monoSmall, - std::to_wstring(tabLane.edgeCenterSmooth)) + - 20.0f * m_scale); + const float fieldWidth = + std::max(kEdgePanelEditFieldWidth * m_scale, + UiMeasureTextWidth(m_fonts->monoSmall, std::to_wstring(shownValue)) + + 20.0f * m_scale); const float fieldLeft = editRect.X + editRect.Width - fieldWidth; const float fieldHeight = editRect.Height - 6.0f * m_scale; const gd::RectF fieldRect(fieldLeft, editRect.Y + 3.0f * m_scale, fieldWidth, fieldHeight); + const PlotEditTarget editTarget = meanFilter ? PlotEditTarget::MEAN_FILTER_RADIUS + : PlotEditTarget::EDGE_CENTER_SMOOTH; const bool editingThis = - (m_editTarget == PlotEditTarget::EDGE_CENTER_SMOOTH && - m_editLane == static_cast(index)); + (m_editTarget == editTarget && m_editLane == static_cast(index)); if (editingThis) { UiDrawEditField(g, fieldRect, m_edit, m_fonts->monoSmall, m_scale); @@ -5994,11 +6145,29 @@ namespace jdi { UiFillRoundRect(g, fieldRect, 3.0f * m_scale, m_theme->pressed); UiDrawRoundRect(g, fieldRect, 3.0f * m_scale, m_theme->border, 1.0f); - UiText(g, std::to_wstring(tabLane.edgeCenterSmooth), m_fonts->monoSmall, - fieldRect, m_theme->textStrong, TextAlign::CENTER); + UiText(g, std::to_wstring(shownValue), m_fonts->monoSmall, fieldRect, + m_theme->textStrong, TextAlign::CENTER); } - (void)labelWidth; - (void)fieldGap; + } + } + + // 均值滤波面板底部按钮行:「构建为新信号」(悬停高亮)。 + if (meanFilter) + { + const gd::RectF& buttonRect = layout.button; + if (buttonRect.Width > 0.0f) + { + const gd::RectF inner(buttonRect.X, buttonRect.Y + 2.0f * m_scale, + buttonRect.Width, buttonRect.Height - 4.0f * m_scale); + const bool hot = + (m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) && + UiPointInRect(inner, static_cast(m_mouseX), + static_cast(m_mouseY)); + UiFillRoundRect(g, inner, 4.0f * m_scale, + hot ? WithAlpha(m_theme->accent, 0x3A) : m_theme->cardBg); + UiDrawRoundRect(g, inner, 4.0f * m_scale, m_theme->accent, 1.2f); + UiText(g, kMeanFilterBuildLabel, m_fonts->uiSmall, inner, m_theme->accent, + TextAlign::CENTER); } } } diff --git a/Utilities/Inc/DataModel.hpp b/Utilities/Inc/DataModel.hpp index b014e5f..3aad796 100644 --- a/Utilities/Inc/DataModel.hpp +++ b/Utilities/Inc/DataModel.hpp @@ -212,6 +212,22 @@ namespace jdi */ bool AddPreparedFile(DataFile&& file, int& fileIndex); + /** + * @brief 在指定文件的指定信号**下方**插入一条新信号(派生信号)。 + * + * 新信号会占据 `afterSignalIndex + 1` 的位置,该位置及其后的信号下标整体后移一位; + * 调用方必须紧接着对每个标签页调用 `TabConfig::RemapAfterSignalInsert()` 修正引用。 + * 采样值会被就地统计(min/max/finiteCount 与粗粒度包络层),显示外观按「全局信号序号」 + * 取调色板颜色(与普通新信号一致)。 + * + * @param [IN] fileIndex 目标文件下标。 + * @param [IN] afterSignalIndex 插入位置的上一条信号下标。 + * @param [IN,OUT] signal 待插入的信号(values 会被统计,其余字段原样使用)。 + * @param [OUT] newSignalIndex 新信号在本文件内的下标。 + * @return 插入成功返回 true。 + */ + bool InsertDerivedSignal(int fileIndex, int afterSignalIndex, SignalData&& signal, int& newSignalIndex); + /** * @brief 移除指定下标的文件,并立即释放其占用的全部采样内存。 * @param [IN] fileIndex 文件下标。 @@ -391,6 +407,27 @@ namespace jdi /** 中心轴统计面板左上角纵坐标(<0 表示自动定位)。 */ double edgeCenterPanelY = -1.0; + + /** + * @brief 「实验性功能 → 均值滤波」开关。 + * + * 开启后为本网格内每条信号额外绘制一条 N 点滑动平均曲线(算法与「边沿计数(中心轴)」 + * 的中心平均值曲线完全一致),并在浮动面板中提供参数 N 与「构建为新信号」按钮。 + * 与其它实验性功能一样属于网格自身状态,重建网格时按信号继承。 + */ + bool meanFilter = false; + + /** 均值滤波半径 N(左右各取 N 个数据点,窗口大小 2N+1)。 */ + int meanFilterRadius = 5; + + /** 均值滤波浮动面板是否展开。 */ + bool meanFilterPanelOpen = true; + + /** 均值滤波面板左上角位置(客户区像素;<0 表示自动定位)。 */ + double meanFilterPanelX = -1.0; + + /** 均值滤波面板左上角纵坐标(<0 表示自动定位)。 */ + double meanFilterPanelY = -1.0; }; /** @@ -404,6 +441,18 @@ namespace jdi */ bool SampleFileAt(const DataFile& file, const SignalData& signal, double time, bool interpolate, double& value); + /** + * @brief 计算 N 点滑动平均(窗口 = [i-N, i+N],边界按实际窗口长度)。 + * + * 与「边沿计数(中心轴)」的中心平均值曲线**完全同一算法**: + * NaN 不参与平均;中心点本身为 NaN 时结果也为 NaN;窗口内无有效值时为 NaN。 + * + * @param [IN] values 原始采样值。 + * @param [IN] radius 平滑半径 N(0 表示原样返回)。 + * @param [OUT] out 输出数组(长度与 values 相同)。 + */ + void ComputeMovingAverage(const std::vector& values, int radius, std::vector& out); + /** * @brief 在指定文件的时间轴上查找与给定时刻最接近的采样点。 * @param [IN] file 目标文件。 @@ -579,8 +628,34 @@ namespace jdi */ int SplitLane(int laneIndex); + /** * @brief 在数据层插入新信号之后,修正本页引用该文件的信号下标。 + * + * 数据层在下标 `insertPos` 处插入了一条信号,因此本页可见性表需插入一列, + * 且所有指向该文件、下标不小于 `insertPos` 的信号引用都要后移一位。 + * + * @param [IN] fileIndex 发生插入的文件下标。 + * @param [IN] insertPos 新信号的下标。 + */ + void RemapAfterSignalInsert(int fileIndex, int insertPos); + /** - * @brief 查询某文件的分组是否处于折叠状态。 + * @brief 把某信号所在的网格移动到另一信号所在网格的下方(紧邻其后)。 + * + * 用于「均值滤波 → 构建为新信号」:新信号所在的网格应紧跟在原信号网格的下方。 + * 移动成功后会切换为自定义划分策略,避免后续重建网格时顺序被复原。 + * + * @param [IN] moved 被移动网格内的任一信号。 + * @param [IN] anchor 锚点信号(其所在网格之后即为目标位置)。 + * @return 发生移动返回 true。 + */ + bool MoveLaneOfSignalBelow(const SignalRef& moved, const SignalRef& anchor); + + /** + * @brief 按当前位置统一重命名全部网格(GX 编号由位置推导,须保持一致)。 + */ + void RenameLanes(); + + /** * @brief 查询某文件的分组是否处于折叠状态。 * @param [IN] fileIndex 文件下标。 * @return 折叠状态。 */ diff --git a/Utilities/Src/DataModel.cpp b/Utilities/Src/DataModel.cpp index 63100f8..f734981 100644 --- a/Utilities/Src/DataModel.cpp +++ b/Utilities/Src/DataModel.cpp @@ -126,6 +126,11 @@ namespace jdi bool edgeCenterPanelOpen = true; double edgeCenterPanelX = -1.0; double edgeCenterPanelY = -1.0; + bool meanFilter = false; + int meanFilterRadius = 5; + bool meanFilterPanelOpen = true; + double meanFilterPanelX = -1.0; + double meanFilterPanelY = -1.0; }; // 先记下每个信号原来所在网格的网格自身状态: @@ -146,6 +151,11 @@ namespace jdi carry.edgeCenterPanelOpen = lane.edgeCenterPanelOpen; carry.edgeCenterPanelX = lane.edgeCenterPanelX; carry.edgeCenterPanelY = lane.edgeCenterPanelY; + carry.meanFilter = lane.meanFilter; + carry.meanFilterRadius = lane.meanFilterRadius; + carry.meanFilterPanelOpen = lane.meanFilterPanelOpen; + carry.meanFilterPanelX = lane.meanFilterPanelX; + carry.meanFilterPanelY = lane.meanFilterPanelY; for (const SignalRef& ref : lane.signals) { previousCarry.emplace_back(ref, carry); @@ -177,6 +187,11 @@ namespace jdi lane.edgeCenterPanelOpen = carry.edgeCenterPanelOpen; lane.edgeCenterPanelX = carry.edgeCenterPanelX; lane.edgeCenterPanelY = carry.edgeCenterPanelY; + lane.meanFilter = carry.meanFilter; + lane.meanFilterRadius = carry.meanFilterRadius; + lane.meanFilterPanelOpen = carry.meanFilterPanelOpen; + lane.meanFilterPanelX = carry.meanFilterPanelX; + lane.meanFilterPanelY = carry.meanFilterPanelY; }; // 收集全部可见信号引用。 @@ -622,6 +637,62 @@ namespace jdi return AddPreparedFile(std::move(file), fileIndex); } + bool DataSet::InsertDerivedSignal(int fileIndex, int afterSignalIndex, SignalData&& signal, + int& newSignalIndex) + { + newSignalIndex = -1; + if (fileIndex < 0 || fileIndex >= static_cast(m_files.size())) + { + return false; + } + + DataFile& file = m_files[static_cast(fileIndex)]; + if (afterSignalIndex < -1 || afterSignalIndex >= static_cast(file.signals.size())) + { + return false; + } + + // 1. 采样值统计与粗粒度包络层:与正常加载路径使用同一套统计逻辑, + // 否则派生信号在大文件下会退化成逐点扫描(包络构建变慢)。 + 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; + + // 2. 显示外观按「全局信号序号」取调色板颜色(与普通新信号完全一致的规则)。 + const int insertPos = afterSignalIndex + 1; + { + // 新信号的全局序号 = 本文件之前的信号总数 + 它在文件内的下标。 + std::size_t globalIndex = static_cast(insertPos); + for (int index = 0; index < fileIndex; ++index) + { + globalIndex += m_files[static_cast(index)].signals.size(); + } + + int paletteCount = 0; + const std::uint32_t* palette = SignalPalette(paletteCount); + if (palette != nullptr && paletteCount > 0) + { + const std::size_t count = static_cast(paletteCount); + const std::size_t slot = globalIndex % count; + signal.style.color = palette[slot]; + signal.style.customColor = palette[(slot + 1) % count]; + } + } + + file.signals.insert(file.signals.begin() + insertPos, std::move(signal)); + + // 3. 全局选中引用:同一文件内位于插入点之后的信号下标整体后移。 + if (m_selected.fileIndex == fileIndex && m_selected.signalIndex >= insertPos) + { + ++m_selected.signalIndex; + } + + newSignalIndex = insertPos; + return true; + } + bool DataSet::RemoveFile(int fileIndex) { if (fileIndex < 0 || fileIndex >= static_cast(m_files.size())) @@ -796,6 +867,86 @@ namespace jdi // ============================ 采样辅助 ============================ + void ComputeMovingAverage(const std::vector& values, int radius, std::vector& out) + { + const int window = std::max(0, radius); + const std::size_t count = values.size(); + out.assign(count, std::numeric_limits::quiet_NaN()); + if (count == 0 || window == 0) + { + // 半径 0 表示不平滑:原样复制(中心点无效处仍为 NaN)。 + for (std::size_t index = 0; index < count; ++index) + { + if (std::isfinite(values[index])) + { + out[index] = static_cast(values[index]); + } + } + return; + } + + // 滑动窗口求和:窗口 = [max(0,i-N), min(count-1,i+N)], + // 每前进一步只增减边界处的两个采样点,整体 O(n),不额外占用前缀和内存。 + double sum = 0.0; + std::size_t finiteCount = 0; + std::size_t windowLow = 0; + std::size_t windowHigh = 0; // 闭区间右端。 + + const auto addValue = [&sum, &finiteCount](double value) + { + if (std::isfinite(value)) + { + sum += value; + ++finiteCount; + } + }; + const auto removeValue = [&sum, &finiteCount](double value) + { + if (std::isfinite(value)) + { + sum -= value; + --finiteCount; + } + }; + + for (std::size_t index = 0; index < count; ++index) + { + const std::size_t low = (index > static_cast(window)) + ? (index - static_cast(window)) + : 0; + const std::size_t high = std::min(count - 1, index + static_cast(window)); + + if (index == 0) + { + windowLow = low; + windowHigh = high; + for (std::size_t i = windowLow; i <= windowHigh; ++i) + { + addValue(values[i]); + } + } + else + { + while (windowLow < low) + { + removeValue(values[windowLow]); + ++windowLow; + } + while (windowHigh < high) + { + ++windowHigh; + addValue(values[windowHigh]); + } + } + + // 中心点本身无效(NaN)时该处不产生有效结果。 + if (finiteCount > 0 && std::isfinite(values[index])) + { + out[index] = static_cast(sum / static_cast(finiteCount)); + } + } + } + int FindNearestSample(const DataFile& file, double time) { const std::size_t count = file.time.size(); @@ -1247,6 +1398,11 @@ namespace jdi lane.edgeCenterPanelOpen = source.edgeCenterPanelOpen; lane.edgeCenterPanelX = source.edgeCenterPanelX; lane.edgeCenterPanelY = source.edgeCenterPanelY; + lane.meanFilter = source.meanFilter; + lane.meanFilterRadius = source.meanFilterRadius; + lane.meanFilterPanelOpen = source.meanFilterPanelOpen; + lane.meanFilterPanelX = source.meanFilterPanelX; + lane.meanFilterPanelY = source.meanFilterPanelY; lane.signals.push_back(ref); pieces.push_back(std::move(lane)); } @@ -1263,8 +1419,72 @@ namespace jdi return laneIndex; } + void TabConfig::RemapAfterSignalInsert(int fileIndex, int insertPos) + { + if (fileIndex < 0 || insertPos < 0) + { + return; + } + + // 可见性表:在插入点补一列(新信号默认不可见,由调用方按需勾选)。 + const std::size_t slot = static_cast(fileIndex); + if (slot < m_visible.size() && static_cast(insertPos) <= m_visible[slot].size()) + { + m_visible[slot].insert(m_visible[slot].begin() + insertPos, false); + } + + // 网格内的信号引用:同一文件中位于插入点之后的引用下标整体后移。 + for (Lane& lane : m_lanes) + { + for (SignalRef& ref : lane.signals) + { + if (ref.fileIndex == fileIndex && ref.signalIndex >= insertPos) + { + ++ref.signalIndex; + } + } + } + } + + bool TabConfig::MoveLaneOfSignalBelow(const SignalRef& moved, const SignalRef& anchor) + { + const int from = LaneOfSignal(moved); + if (from < 0 || LaneOfSignal(anchor) < 0) + { + return false; + } + + Lane lane = std::move(m_lanes[static_cast(from)]); + m_lanes.erase(m_lanes.begin() + from); + + // erase 之后锚点网格的下标可能变化,必须用信号引用重新定位。 + const int target = LaneOfSignal(anchor); + if (target < 0) + { + m_lanes.push_back(std::move(lane)); + } + else + { + m_lanes.insert(m_lanes.begin() + target + 1, std::move(lane)); + } + + // 手动排布 → 切换到自定义划分策略,避免后续重建网格时顺序被还原。 + m_laneMode = LaneMode::CUSTOM; + RenameLanes(); + return true; + } + + void TabConfig::RenameLanes() + { + for (std::size_t index = 0; index < m_lanes.size(); ++index) + { + m_lanes[index].name = L"网格 " + std::to_wstring(index + 1); + } + } + bool TabConfig::IsCollapsed(int fileIndex) const - { if (fileIndex < 0 || static_cast(fileIndex) >= m_collapsed.size()) + { + if (fileIndex < 0 || static_cast(fileIndex) >= m_collapsed.size()) { return false; }