From bc8db72f18b03bbfcdcf52e8bc9fa45a9d58dcc8 Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Sat, 10 Oct 2026 15:33:45 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=89=B2=E5=9F=9F=E6=8B=96?= =?UTF-8?q?=E6=8B=BD=E5=9B=9E=E5=BC=B9=E3=80=81=E7=BD=91=E6=A0=BC=20GX=20?= =?UTF-8?q?=E6=8B=96=E6=8B=BD=E9=87=8D=E6=8E=92=E3=80=81=E5=9D=87=E5=80=BC?= =?UTF-8?q?=E6=BB=A4=E6=B3=A2=E5=A4=9A=E9=98=B6=E4=B8=8E=E9=80=90=E4=BF=A1?= =?UTF-8?q?=E5=8F=B7=E6=9D=A1=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 自定义颜色面板:拖动时鼠标移出控件不再"弹回去" - 根因是选点位置每帧都从 RGB 反推:色相 1 与 0 是同一个颜色(红),拖到色相条底部 下一帧反推回 0,指示条弹到顶部;拖到色域方块右下角之外时明度为 0(黑色), 黑色的饱和度无定义,反推得 0,选点弹到最左边。 - 现在把"选点位置(饱和度/明度)与色相"作为独立状态保存,只由对应控件与外部改色维护; 越界一律夹取到最近边界,因此鼠标离开控件后选点停在边界上。 - 统一色域方块、色相条、预览色块与色号框的圆角;RGB 滑块适当加粗; 色域选点去掉外圈深色描边(只保留白色描边),色相指示器改为白色描边的圆角长方条、 底色取该位置的颜色;色相条改用分段线性渐变绘制。 2. 网格 GX 编号支持拖拽重排 - 按住网格图例左侧的 GX 徽标即可上下拖动,整块网格(连同波形与标尺)跟随鼠标移动; 途经的网格保持自身大小、整体让出一格,目标槽位有吸附高亮提示。 - 松开后真正重排并从下到上/从上到下重新编号,信号列表徽标、图例徽标、菜单与浮动面板 中所有 GX 编号同步更新;手动排布后切换为自定义划分,后续重建网格不会打乱次序。 - 拖动期间复用静态场景缓存,按网格条带平移贴图,因此大文件下依然流畅。 3. 均值滤波升级为"均值点数 X + 阶数 N" - 原"均值点数 N"更名"均值点数 X",新增"阶数 N":预览曲线以 X 点均值滤波的结果为蓝本, 再重复同样的滤波共 N 阶(1 阶即与原来的单次滤波逐位一致)。 - 浮动面板改为逐信号条目:第一排是信号名与复选框,第二排是均值点数与阶数输入; 只有勾选的信号才绘制预览曲线,未勾选时不显示第二排;启用功能时默认只勾选第一个信号。 - 参数按信号保存(同一网格内不同信号可用不同参数),「构建为新信号」按各自参数构建。 --- Components/Application/Inc/Application.hpp | 8 +- Components/Application/Src/Application.cpp | 54 +- Components/PlotView/Inc/PlotView.hpp | 166 ++++- Components/PlotView/Src/PlotView.cpp | 810 ++++++++++++++++++--- Utilities/Inc/ColorPickerPopup.hpp | 12 +- Utilities/Inc/DataModel.hpp | 67 +- Utilities/Src/ColorPickerPopup.cpp | 69 +- Utilities/Src/DataModel.cpp | 133 +++- 8 files changed, 1138 insertions(+), 181 deletions(-) diff --git a/Components/Application/Inc/Application.hpp b/Components/Application/Inc/Application.hpp index 037f689..a776dcf 100644 --- a/Components/Application/Inc/Application.hpp +++ b/Components/Application/Inc/Application.hpp @@ -250,14 +250,16 @@ namespace jdi bool ApplyPendingMeanFilterBuild(); /** - * @brief 把一条均值滤波派生信号插入数据层并同步各视图。 + * @brief 把一条多阶均值滤波派生信号插入数据层并同步各视图。 * @param [IN] fileIndex 原信号所属文件下标。 * @param [IN] signalIndex 原信号下标。 - * @param [IN] radius 均值滤波半径 N。 + * @param [IN] points 均值点数 X(滑平半径)。 + * @param [IN] order 阶数 N(迭代次数)。 * @param [IN] makeVisible 是否在当前标签页中勾选新信号。 * @return 构建并插入成功返回 true。 */ - bool BuildMeanFilterSignal(int fileIndex, int signalIndex, int radius, bool makeVisible); + bool BuildMeanFilterSignal(int fileIndex, int signalIndex, int points, int order, + bool makeVisible); /** * @brief 弹出文件选择对话框并加载所选文件。 diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index 7bdfc77..6647108 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -1436,7 +1436,8 @@ namespace jdi return true; } - bool Application::BuildMeanFilterSignal(int fileIndex, int signalIndex, int radius, bool makeVisible) + bool Application::BuildMeanFilterSignal(int fileIndex, int signalIndex, int points, int order, + bool makeVisible) { const DataFile* file = m_dataSet.FileAt(fileIndex); if (file == nullptr || signalIndex < 0 || signalIndex >= static_cast(file->signals.size())) @@ -1446,13 +1447,18 @@ namespace jdi const SignalData& source = file->signals[static_cast(signalIndex)]; const std::wstring baseName = source.Title(); - // 1. 计算均值滤波采样值(与预览曲线共用同一实现)。 + // 1. 计算多阶均值滤波采样值(与预览曲线共用同一实现)。 std::vector filtered; - ComputeMovingAverage(source.values, radius, filtered); + ComputeMovingAverageIterated(source.values, points, order, filtered); - // 2. 组装新信号:名称形如「<原信号名称> (N点均值)」。 + // 2. 组装新信号:名称形如「<原信号名称> (N点均值x阶)」。 SignalData derived; - derived.name = baseName + L" (" + std::to_wstring(radius) + L"点均值)"; + derived.name = baseName + L" (" + std::to_wstring(points) + L"点均值"; + if (order > 1) + { + derived.name += L"×" + std::to_wstring(order) + L"阶"; + } + derived.name += L")"; derived.rawHeader = derived.name; derived.unit = source.unit; derived.values.assign(filtered.begin(), filtered.end()); @@ -1490,8 +1496,8 @@ namespace jdi bool Application::ApplyPendingMeanFilterBuild() { int laneIndex = -1; - int radius = 0; - if (!m_plot.TakePendingMeanFilterBuild(laneIndex, radius)) + std::vector items; + if (!m_plot.TakePendingMeanFilterBuild(laneIndex, items)) { return false; } @@ -1501,21 +1507,10 @@ namespace jdi 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) + std::sort(items.begin(), items.end(), + [](const PlotView::MeanFilterBuild& a, const PlotView::MeanFilterBuild& b) { if (a.fileIndex != b.fileIndex) { @@ -1525,19 +1520,28 @@ namespace jdi }); int built = 0; - for (const SignalRef& ref : targets) + int lastPoints = 0; + int lastOrder = 1; + for (const PlotView::MeanFilterBuild& item : items) { - if (BuildMeanFilterSignal(ref.fileIndex, ref.signalIndex, radius, true)) + if (BuildMeanFilterSignal(item.fileIndex, item.signalIndex, item.points, item.order, true)) { ++built; + lastPoints = item.points; + lastOrder = item.order; } } if (built > 0) { - SetStatusMessage(L"已构建 " + std::to_wstring(built) + L" 条 " + std::to_wstring(radius) + - L"点均值滤波信号。", - false); + std::wstring text = L"已构建 " + std::to_wstring(built) + L" 条 " + + std::to_wstring(lastPoints) + L"点均值"; + if (lastOrder > 1) + { + text += L"×" + std::to_wstring(lastOrder) + L"阶"; + } + text += L"滤波信号。"; + SetStatusMessage(text, false); } return built > 0; } diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index ffa556f..54ebbc1 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -29,7 +29,8 @@ namespace jdi CURSOR_SPAN_MOVE, /**< 拖拽双光标差值标签:两条光标整体平移,间隔不变。 */ MOVE_SIGNAL, /**< 拖拽信号曲线到其它网格。 */ EDGE_MOVE, /**< 拖动「边沿计数」的横向光标(上下移动幅值)。 */ - EDGE_PANEL_MOVE /**< 拖动边沿计数浮动面板。 */ + EDGE_PANEL_MOVE, /**< 拖动边沿计数浮动面板。 */ + LANE_REORDER /**< 拖动网格图例中的 GX 编号徽标:上下调整网格次序。 */ }; /** @@ -54,7 +55,8 @@ namespace jdi LANE_Y_MIN, /**< 编辑某网格的 Y 轴最小值。 */ SIGNAL_NAME, /**< 编辑图例中某信号的显示名称。 */ EDGE_CENTER_SMOOTH, /**< 编辑「中心轴边沿计数」的平滑点数 N。 */ - MEAN_FILTER_RADIUS /**< 编辑「均值滤波」的均值点数 N。 */ + MEAN_FILTER_POINTS, /**< 编辑「均值滤波」的均值点数 X。 */ + MEAN_FILTER_ORDER /**< 编辑「均值滤波」的阶数 N。 */ }; /** @@ -492,17 +494,28 @@ namespace jdi */ bool ToggleLaneMeanFilter(int laneIndex); + /** + * @brief 「均值滤波 → 构建为新信号」的单个请求(一个被勾选的信号 + 自己的参数)。 + */ + struct MeanFilterBuild + { + int fileIndex = -1; /**< 信号所属文件下标。 */ + int signalIndex = -1; /**< 文件内信号下标。 */ + int points = 5; /**< 均值点数 X(滑平半径)。 */ + int order = 1; /**< 阶数 N(迭代次数)。 */ + }; + /** * @brief 取出「构建为新信号」请求(由面板底部按钮触发)。 * - * 请求只描述「哪一网格、用多大的 N」,真正的信号构建与插入由宿主(Application) - * 完成——数据层属于 DataSet,绘图区不能直接修改它。 + * 请求只描述「哪一网格、哪些被勾选的信号及其参数」,真正的信号构建与插入由宿主 + * (Application)完成——数据层属于 DataSet,绘图区不能直接修改它。 * * @param [OUT] laneIndex 触发该请求的网格下标。 - * @param [OUT] radius 均值滤波半径 N。 + * @param [OUT] items 需要构建的信号及其参数(按信号在网格内的顺序)。 * @return 存在待处理请求返回 true(取出后清空)。 */ - bool TakePendingMeanFilterBuild(int& laneIndex, int& radius); + bool TakePendingMeanFilterBuild(int& laneIndex, std::vector& items); /** * @brief 直接把文本解析为网格 Y 轴上限/下限并应用(供菜单内联输入框提交使用)。 @@ -938,6 +951,17 @@ namespace jdi gd::RectF edit; /**< 面板底部编辑行(无编辑内容时宽度为 0)。 */ gd::RectF button; /**< 面板底部按钮(无按钮时宽度为 0)。 */ int rows = 0; /**< 统计行数(每条可见信号一行)。 */ + /** + * @brief 均值滤波面板的逐信号条目矩形(仅 MEAN 面板使用)。 + * + * 索引与 `rowRefs` 一一对应(即网格内可见信号的顺序): + * `rowChecks` 为第一排右侧的复选框;`rowPoints` / `rowOrder` 为第二排的两个输入框 + * (未勾选的条目两个矩形均为空)。 + */ + std::vector rowChecks; + std::vector rowPoints; + std::vector rowOrder; + std::vector rowRefs; }; /** @@ -949,7 +973,50 @@ namespace jdi EdgePanelLayout EdgePanelLayoutFor(int laneIndex, EdgePanelKind kind) const; /** - * @brief 获取中心平均值曲线(每个数据点 = 自身与左右各 N 点的平均值),结果带缓存。 + * @brief 获取多阶(迭代)均值滤波曲线,结果带缓存。 + * + * 语义:以「均值点数 X」作滑平半径,对输入重复做 `order` 次滑动平均; + * `order == 1` 时与 `CenterCurveFor` 完全一致。 + * + * @param [IN] signal 目标信号。 + * @param [IN] points 均值点数 X(滑平半径)。 + * @param [IN] order 阶数 N(迭代次数,至少 1)。 + * @return 曲线数值数组(长度与 `signal.values` 相同;缺失处为 NaN)。 + */ + const std::vector& MeanFilterCurveFor(const SignalData& signal, int points, + int order) const; + + /** + * @brief 取某个时刻的多阶均值滤波曲线取值(用于纵向光标的取值标注)。 + * @param [IN] file 信号所属文件。 + * @param [IN] signal 目标信号。 + * @param [IN] points 均值点数 X。 + * @param [IN] order 阶数 N。 + * @param [IN] time 采样时刻。 + * @param [OUT] value 输出取值。 + * @return 取到有效值返回 true。 + */ + bool MeanFilterValueAt(const DataFile& file, const SignalData& signal, int points, int order, + double time, double& value) const; + + /** + * @brief 切换某个信号在「均值滤波」面板中的预览勾选状态。 + * @param [IN] laneIndex 网格下标。 + * @param [IN] ref 目标信号。 + * @return 切换后的勾选状态。 + */ + bool ToggleLaneMeanFilterSignal(int laneIndex, const SignalRef& ref); + + /** + * @brief 查询某个信号在「均值滤波」面板中的预览勾选状态。 + * @param [IN] laneIndex 网格下标。 + * @param [IN] ref 目标信号。 + * @return 已勾选返回 true。 + */ + bool LaneMeanFilterSignalEnabled(int laneIndex, const SignalRef& ref) const; + + /** + * @brief 获取多阶(迭代)均值滤波曲线,结果带缓存。 * @param [IN] signal 目标信号。 * @param [IN] smooth 平滑半径 N(0 表示不平滑)。 * @return 曲线数值数组(长度与 `signal.values` 相同;缺失处为 NaN)。 @@ -994,26 +1061,25 @@ namespace jdi int smooth) const; /** - * @brief 绘制某条信号的均值滤波曲线(与中心平均值曲线同一算法与同一配色派生规则)。 + * @brief 绘制一段派生曲线(中心平均值 / 多阶均值滤波共用)。 * @param [IN,OUT] g 绘图上下文。 * @param [IN] resolved 信号视图。 * @param [IN] lane 网格绘制信息。 - * @param [IN] radius 均值滤波半径 N。 + * @param [IN] curve 曲线数值数组(与信号采样等长,缺失处为 NaN)。 */ - void PaintMeanFilterCurve(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane, - int radius) const; + void PaintDerivedCurve(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane, + const std::vector& curve) const; /** - * @brief 取某个时刻的均值滤波曲线取值(用于纵向光标的取值标注)。 - * @param [IN] file 信号所属文件。 - * @param [IN] signal 目标信号。 - * @param [IN] radius 均值滤波半径 N。 - * @param [IN] time 采样时刻。 - * @param [OUT] value 输出取值。 - * @return 取到有效值返回 true。 + * @brief 绘制某条信号的多阶均值滤波曲线(与中心平均值曲线同一配色派生规则)。 + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] resolved 信号视图。 + * @param [IN] lane 网格绘制信息。 + * @param [IN] points 均值点数 X(滑平半径)。 + * @param [IN] order 阶数 N(迭代次数)。 */ - bool MeanFilterValueAt(const DataFile& file, const SignalData& signal, int radius, double time, - double& value) const; + void PaintMeanFilterCurve(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane, + int points, int order) const; /** * @brief 数据点标记的位置与"越界方向"(用于决定画圆点还是贴边三角)。 @@ -1216,6 +1282,12 @@ namespace jdi PlotEditTarget m_editTarget = PlotEditTarget::NONE; /**< 当前内联输入目标。 */ TextEdit m_edit; /**< 输入框编辑模型(光标/选区/剪贴板)。 */ int m_editLane = -1; /**< 内联输入所作用的网格下标。 */ + SignalRef m_editMeanRef; /**< 均值滤波参数编辑所作用的信号。 */ + /** 网格拖拽重排状态(拖动图例中的 GX 徽标)。 */ + int m_laneDragFrom = -1; /**< 被拖拽的网格下标,-1 表示未拖拽。 */ + int m_laneDragTarget = -1; /**< 当前插入位置(0..网格数)。 */ + int m_laneDragMouseY = 0; /**< 最近一次鼠标纵坐标(被拖网格自由跟随)。 */ + float m_laneDragGrabOffset = 0.0f; /**< 抓取时鼠标相对网格顶边的偏移。 */ SignalRef m_editSignal; /**< 内联输入所作用的信号(图例改名)。 */ gd::RectF m_editFieldRect; /**< 本次编辑输入框的矩形(用于点外提交判定)。 */ SignalRef m_pendingRenameRef; /**< 待宿主窗口应用的信号重命名引用。 */ @@ -1223,7 +1295,7 @@ namespace jdi bool m_pendingRename = false; /**< 是否存在待应用的重命名请求。 */ int m_pendingMeanFilterLane = -1; /**< 待构建的均值滤波信号所属网格下标(-1 表示无请求)。 */ - int m_pendingMeanFilterRadius = 0; /**< 待构建的均值滤波半径 N。 */ + std::vector m_pendingMeanFilterItems; /**< 待构建的信号及其参数。 */ /** * @brief 实验性功能浮动面板的 ✕ 处于"已按下、等待抬起确认"状态。 @@ -1279,6 +1351,16 @@ namespace jdi /** 中心平均值曲线缓存:**deque** 保证 push_back 后已有元素引用仍然有效。 */ mutable std::deque m_centerCurveCache; + /** 多阶均值滤波曲线缓存项(按「信号指针 + 均值点数 + 阶数」标识)。 */ + struct MeanCurveEntry + { + const SignalData* signal = nullptr; + int points = 5; + int order = 1; + std::vector values; /**< 与 signal->values 等长;缺失处为 NaN。 */ + }; + mutable std::deque m_meanCurveCache; + /** 中心轴边沿统计缓存项(增量统计机制与 `EdgeCountEntry` 完全一致)。 */ struct EdgeCenterCountEntry { @@ -1497,6 +1579,48 @@ namespace jdi void PaintSignalHalo(gd::Graphics& g, const ResolvedSignal& resolved, const std::vector& points, const std::vector& breaks); + /** + * @brief 计算某网格图例中 GX 编号徽标的矩形(绘制与命中测试共用,保证一致)。 + * @param [IN] laneIndex 网格下标。 + * @return 徽标矩形;网格不可见时返回空矩形。 + */ + gd::RectF LaneBadgeRect(int laneIndex) const; + + /** + * @brief 查询坐标命中的网格编号徽标(用于发起网格次序拖拽)。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @return 命中的网格下标,未命中返回 -1。 + */ + int LaneBadgeAtPoint(int x, int y) const; + + /** + * @brief 网格拖拽重排时某条网格的显示位移(像素)。 + * + * 被拖拽的网格自由跟随鼠标;它路径上的其它网格整体上/下让位一格。 + * @param [IN] laneIndex 网格下标。 + * @return 纵向位移。 + */ + float LaneDragOffset(int laneIndex) const; + + /** + * @brief 网格拖拽重排:按鼠标位置计算插入位置(0..网格数)。 + * @param [IN] mouseY 鼠标客户区纵坐标。 + * @return 插入位置。 + */ + int LaneDragTargetAt(int mouseY) const; + + /** + * @brief 绘制网格拖拽重排过程中的内容(逐网格按位移贴图 + 目标位置高亮)。 + * @param [IN,OUT] g 绘图上下文。 + */ + void PaintLaneReorder(gd::Graphics& g); + + /** + * @brief 结束网格拖拽重排:若目标位置与原位置不同则真正重排并同步编号。 + */ + void FinishLaneReorder(); + /** * @brief 确保离屏缓存位图存在且尺寸匹配。 * @param [IN] width 位图宽度(像素)。 diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index baf427f..4211213 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -61,6 +61,10 @@ namespace jdi const float kEdgePanelEditFieldWidth = 64.0f; /**< 编辑行数值框的最小宽度。 */ const float kEdgePanelEditFieldGap = 8.0f; /**< 编辑行说明文字与数值框之间的间隔。 */ const float kEdgePanelButtonHeight = 30.0f; /**< 均值滤波面板底部按钮行高度。 */ + const float kEdgePanelMeanRowHeight = 22.0f; /**< 均值滤波面板每条信号的名称行高度。 */ + const float kEdgePanelMeanEditHeight = 24.0f; /**< 均值滤波面板每条信号的参数行高度。 */ + const float kEdgePanelMeanRowGap = 2.0f; /**< 名称行与参数行之间的间距。 */ + const float kEdgePanelMeanCheckSize = 15.0f; /**< 均值滤波面板条目复选框尺寸。 */ const wchar_t* const kMeanFilterBuildLabel = L"构建为新信号"; /**< 均值滤波面板底部按钮文案。 */ const std::size_t kEdgeCountCacheLimit = 48; /**< 边沿统计缓存条目上限(超出时淘汰最早的一条)。 */ @@ -1658,9 +1662,18 @@ namespace jdi lane.meanFilter = enabled; if (enabled) { - if (lane.meanFilterRadius < 0) + // 默认只勾选网格内**第一个可见信号**(用户明确要求),其余保持未勾选。 + if (lane.meanFilterItems.empty()) { - lane.meanFilterRadius = 0; + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data != nullptr && resolved.visible) + { + lane.EnsureMeanFilterItem(ref); + break; + } + } } lane.meanFilterPanelOpen = true; } @@ -1671,6 +1684,31 @@ namespace jdi return true; } + bool PlotView::ToggleLaneMeanFilterSignal(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.MeanFilterEnabled(ref)) + { + lane.RemoveMeanFilterItem(ref); + return false; + } + lane.EnsureMeanFilterItem(ref); + return true; + } + + bool PlotView::LaneMeanFilterSignalEnabled(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)].MeanFilterEnabled(ref); + } + bool PlotView::ToggleLaneMeanFilter(int laneIndex) { const bool enabled = !LaneMeanFilter(laneIndex); @@ -1678,16 +1716,16 @@ namespace jdi return enabled; } - bool PlotView::TakePendingMeanFilterBuild(int& laneIndex, int& radius) + bool PlotView::TakePendingMeanFilterBuild(int& laneIndex, std::vector& items) { if (m_pendingMeanFilterLane < 0) { return false; } laneIndex = m_pendingMeanFilterLane; - radius = m_pendingMeanFilterRadius; + items = m_pendingMeanFilterItems; m_pendingMeanFilterLane = -1; - m_pendingMeanFilterRadius = 0; + m_pendingMeanFilterItems.clear(); return true; } @@ -1735,11 +1773,51 @@ namespace jdi return true; } - bool PlotView::MeanFilterValueAt(const DataFile& file, const SignalData& signal, int radius, + bool PlotView::MeanFilterValueAt(const DataFile& file, const SignalData& signal, int points, int order, double time, double& value) const { - // 均值滤波曲线与中心平均值曲线是同一算法,取值路径完全一致。 - return CenterValueAt(file, signal, radius, time, value); + const int index = FindNearestSample(file, time); + if (index < 0) + { + return false; + } + const std::vector& curve = MeanFilterCurveFor(signal, points, order); + if (static_cast(index) >= curve.size()) + { + return false; + } + const float sampled = curve[static_cast(index)]; + if (!std::isfinite(sampled)) + { + return false; + } + value = static_cast(sampled); + return true; + } + + const std::vector& PlotView::MeanFilterCurveFor(const SignalData& signal, int points, + int order) const + { + const int radius = std::max(0, points); + const int stages = std::max(1, order); + for (const MeanCurveEntry& entry : m_meanCurveCache) + { + if (entry.signal == &signal && entry.points == radius && entry.order == stages) + { + return entry.values; + } + } + + MeanCurveEntry entry; + entry.signal = &signal; + entry.points = radius; + entry.order = stages; + // 与「构建为新信号」共用同一实现(Utilities 内的自由函数), + // 因此预览曲线与构建出的采样值逐位一致。 + ComputeMovingAverageIterated(signal.values, radius, stages, entry.values); + + m_meanCurveCache.push_back(std::move(entry)); + return m_meanCurveCache.back().values; } void PlotView::EdgeCenterCountsFor(const DataFile& file, const SignalData& signal, int smooth, @@ -2018,11 +2096,11 @@ namespace jdi width = std::max(width, kEdgePanelPad * 2.0f * m_scale + std::max(nameWidth, countWidth)); } - if (centerAxis || meanFilter) + if (centerAxis) { - // 底部编辑行 = “平滑点数 / 均值点数”说明文字 + 数值框(与菜单内联编辑同一套排版习惯)。 - const int shown = meanFilter ? lane.meanFilterRadius : lane.edgeCenterSmooth; - const std::wstring label = meanFilter ? L"均值点数 N" : L"平滑点数 N"; + // 底部编辑行 = “平滑点数 N”说明文字 + 数值框(与菜单内联编辑同一套排版习惯)。 + const int shown = lane.edgeCenterSmooth; + const std::wstring label = L"平滑点数 N"; const float editWidth = UiMeasureTextWidth(m_fonts->uiSmall, label) + kEdgePanelEditFieldGap * m_scale + std::max(kEdgePanelEditFieldWidth * m_scale, @@ -2036,6 +2114,17 @@ namespace jdi const float buttonWidth = UiMeasureTextWidth(m_fonts->uiSmall, kMeanFilterBuildLabel) + 28.0f * m_scale; width = std::max(width, kEdgePanelPad * 2.0f * m_scale + buttonWidth); + + // 参数行 = 两个「说明文字 + 数值框」,必须保证文字不被数值框压住。 + const float labelX = UiMeasureTextWidth(m_fonts->uiSmall, L"均值点数 X"); + const float labelN = UiMeasureTextWidth(m_fonts->uiSmall, L"阶数 N"); + const float fieldWidth = + std::max(kEdgePanelEditFieldWidth * m_scale * 0.75f, + UiMeasureTextWidth(m_fonts->monoSmall, L"1000000") + 18.0f * m_scale); + const float halfWidth = + std::max(labelX, labelN) + 6.0f * m_scale + fieldWidth; + const float rowWidth = halfWidth * 2.0f + 8.0f * m_scale; + width = std::max(width, kEdgePanelPad * 2.0f * m_scale + rowWidth); } // 夹在绘图区宽度之内(过窄的网格也不至于把面板挤到外面)。 @@ -2555,17 +2644,23 @@ namespace jdi } if (m_editTarget == PlotEditTarget::EDGE_CENTER_SMOOTH || - m_editTarget == PlotEditTarget::MEAN_FILTER_RADIUS) + m_editTarget == PlotEditTarget::MEAN_FILTER_POINTS || + m_editTarget == PlotEditTarget::MEAN_FILTER_ORDER) { - // 「平滑点数 / 均值点数 N」:输入框落在面板底部的编辑行内(靠右,与显示态对齐)。 + // 「平滑点数 N」/「均值点数 X」/「阶数 N」:输入框就落在对应的字段矩形上。 if (m_editFieldRect.Width <= 0.0f) { return gd::RectF(); } - const float fieldWidth = std::max(width, kEdgePanelEditFieldWidth * m_scale); - const float fieldHeight = std::max(height, m_editFieldRect.Height - 6.0f * m_scale); - return gd::RectF(m_editFieldRect.X + m_editFieldRect.Width - fieldWidth, - m_editFieldRect.Y + 3.0f * m_scale, fieldWidth, fieldHeight); + const float fieldHeight = std::max(height, m_editFieldRect.Height - 4.0f * m_scale); + if (m_editTarget == PlotEditTarget::EDGE_CENTER_SMOOTH) + { + const float fieldWidth = std::max(width, kEdgePanelEditFieldWidth * m_scale); + return gd::RectF(m_editFieldRect.X + m_editFieldRect.Width - fieldWidth, + m_editFieldRect.Y + 3.0f * m_scale, fieldWidth, fieldHeight); + } + return gd::RectF(m_editFieldRect.X, m_editFieldRect.Y + 2.0f * m_scale, + m_editFieldRect.Width, fieldHeight); } // 网格 Y 轴上下限输入框:优先落在被点击的菜单条目位置,保证"就地编辑"的观感。 @@ -2737,14 +2832,39 @@ namespace jdi return true; } - // 「均值点数 N」:只作用于均值滤波曲线的计算,夹在合理范围内(0–1000000)。 - if (target == PlotEditTarget::MEAN_FILTER_RADIUS) + // 「均值点数 X」/「阶数 N」:作用于被勾选信号的均值滤波曲线,夹在合理范围内。 + if (target == PlotEditTarget::MEAN_FILTER_POINTS || target == PlotEditTarget::MEAN_FILTER_ORDER) { - 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(); + const Lane::MeanFilterItem* existing = + m_tab->Lanes()[static_cast(laneIndex)].FindMeanFilterItem(m_editMeanRef); + if (existing == nullptr) + { + return true; + } + const int maximum = (target == PlotEditTarget::MEAN_FILTER_POINTS) ? 1000000 : 1000; + int value = static_cast(std::lround(parsed)); + if (target == PlotEditTarget::MEAN_FILTER_ORDER) + { + // 阶数至少为 1(0 阶等于不做滤波,没有意义)。 + value = std::max(1, std::min(value, maximum)); + } + else + { + value = std::max(0, std::min(value, maximum)); + } + + Lane::MeanFilterItem& item = + m_tab->Lanes()[static_cast(laneIndex)].EnsureMeanFilterItem(m_editMeanRef); + if (target == PlotEditTarget::MEAN_FILTER_POINTS) + { + item.points = value; + } + else + { + item.order = value; + } + // 参数变化会改变曲线:清掉派生缓存后重绘。 + m_meanCurveCache.clear(); InvalidateSceneCache(); return true; } @@ -2951,6 +3071,9 @@ namespace jdi case PlotDragMode::EDGE_PANEL_MOVE: // 边沿统计浮动面板:可自由拖动。 return LoadCursorW(nullptr, IDC_SIZEALL); + case PlotDragMode::LANE_REORDER: + // 网格次序拖拽:整块网格跟随鼠标上下移动。 + return LoadCursorW(nullptr, IDC_SIZEALL); default: break; } @@ -3001,6 +3124,12 @@ namespace jdi } } + // 图例中的 GX 编号徽标:悬停即可上下拖动调整网格次序 → 上下移动光标提示。 + if (LaneBadgeAtPoint(x, y) >= 0) + { + return LoadCursorW(nullptr, IDC_SIZEALL); + } + // 光标时间读数标签与双光标差值标签:**悬停(未按下)时不改变鼠标形状**。 // 两个标签都叠在光标线的正上方,因此必须先于"光标线"判定返回 nullptr, // 由调用方回退成箭头;只有真正拖拽时才切成左右箭头(见 CURSOR_MOVE / CURSOR_SPAN_MOVE)。 @@ -3189,6 +3318,18 @@ namespace jdi } return true; } + case PlotDragMode::LANE_REORDER: + { + // 被拖拽的网格自由跟随鼠标;目标位置变化时其它网格实时让位(吸附)。 + m_laneDragMouseY = y; + const int target = LaneDragTargetAt(y); + if (target != m_laneDragTarget) + { + m_laneDragTarget = target; + } + m_dragMoved = true; + return true; + } case PlotDragMode::EDGE_MOVE: { // 横向光标:上下移动幅值(夹在该网格的可见幅值范围内)。 @@ -3421,6 +3562,24 @@ namespace jdi } } + // 网格次序拖拽:按在图例的 GX 编号徽标上即可整块拎起该网格上下移动。 + // 位置在浮动面板之后(面板压在波形之上,应优先命中),但在信号拖拽之前。 + { + const int badgeLane = LaneBadgeAtPoint(x, y); + if (badgeLane >= 0) + { + m_dragMode = PlotDragMode::LANE_REORDER; + m_laneDragFrom = badgeLane; + m_laneDragTarget = badgeLane; + m_laneDragMouseY = y; + m_laneDragGrabOffset = + static_cast(y) - m_lanes[static_cast(badgeLane)].rect.Y; + // 拖动期间保持静态场景缓存有效:内容按网格条带平移贴回, + // 因此不必重绘百万级采样的波形(见 PaintLaneReorder)。 + return true; + } + } + // 实验性功能浮动面板(面板压在波形之上,必须先于信号拖拽判定): // * ✕ → 关闭该网格对应的功能; // * 底部编辑行 → 进入「平滑点数 / 均值点数 N」内联编辑; @@ -3450,35 +3609,91 @@ namespace jdi m_pendingPanelCloseKind = kind; } - if ((kind == EdgePanelKind::CENTER || kind == EdgePanelKind::MEAN) && - layout.edit.Width > 0.0f && UiPointInRect(layout.edit, fx, fy) && m_tab != nullptr) + // 均值滤波面板:逐信号条目。 + if (kind == EdgePanelKind::MEAN && m_tab != nullptr) { - // 底部编辑行 → 就地编辑参数 N(输入框落在该行内)。 - const Lane& tabLane = m_tab->Lanes()[static_cast(index)]; - if (kind == EdgePanelKind::MEAN) + Lane& tabLane = m_tab->Lanes()[static_cast(index)]; + for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) { - BeginEdit(PlotEditTarget::MEAN_FILTER_RADIUS, index, - std::to_wstring(tabLane.meanFilterRadius), layout.edit); + const SignalRef& ref = layout.rowRefs[rowIndex]; + // 第一排右侧的复选框 → 切换该信号的预览勾选状态。 + if (layout.rowChecks[rowIndex].Width > 0.0f && + UiPointInRect(layout.rowChecks[rowIndex], fx, fy)) + { + ToggleLaneMeanFilterSignal(index, ref); + m_meanCurveCache.clear(); + InvalidateSceneCache(); + m_dragMode = PlotDragMode::NONE; + return true; + } + // 第二排的两个输入框 → 就地编辑该信号的均值点数 X / 阶数 N。 + if (layout.rowPoints[rowIndex].Width > 0.0f && + UiPointInRect(layout.rowPoints[rowIndex], fx, fy)) + { + const Lane::MeanFilterItem* item = tabLane.FindMeanFilterItem(ref); + m_editMeanRef = ref; + BeginEdit(PlotEditTarget::MEAN_FILTER_POINTS, index, + std::to_wstring(item != nullptr ? item->points : 5), + layout.rowPoints[rowIndex]); + return true; + } + if (layout.rowOrder[rowIndex].Width > 0.0f && + UiPointInRect(layout.rowOrder[rowIndex], fx, fy)) + { + const Lane::MeanFilterItem* item = tabLane.FindMeanFilterItem(ref); + m_editMeanRef = ref; + BeginEdit(PlotEditTarget::MEAN_FILTER_ORDER, index, + std::to_wstring(item != nullptr ? item->order : 1), + layout.rowOrder[rowIndex]); + return true; + } } - else + + if (layout.button.Width > 0.0f && UiPointInRect(layout.button, fx, fy)) { - BeginEdit(PlotEditTarget::EDGE_CENTER_SMOOTH, index, - std::to_wstring(tabLane.edgeCenterSmooth), layout.edit); + // 「构建为新信号」:只登记请求(含每个信号自己的 X/N), + // 真正的信号构建由宿主(Application)完成。 + const Lane& lane = m_tab->Lanes()[static_cast(index)]; + m_pendingMeanFilterItems.clear(); + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || !resolved.visible) + { + continue; + } + const Lane::MeanFilterItem* item = lane.FindMeanFilterItem(ref); + if (item == nullptr) + { + continue; + } + MeanFilterBuild build; + build.fileIndex = ref.fileIndex; + build.signalIndex = ref.signalIndex; + build.points = item->points; + build.order = item->order; + m_pendingMeanFilterItems.push_back(build); + } + if (!m_pendingMeanFilterItems.empty()) + { + const MeanFilterBuild& first = m_pendingMeanFilterItems.front(); + SetHint(L"正在构建均值滤波信号(" + std::to_wstring(first.points) + + L" 点 " + std::to_wstring(first.order) + L" 阶,共 " + + std::to_wstring(m_pendingMeanFilterItems.size()) + L" 条)…"); + m_pendingMeanFilterLane = index; + } + m_dragMode = PlotDragMode::NONE; + return true; } - return true; } - if (kind == EdgePanelKind::MEAN && layout.button.Width > 0.0f && - UiPointInRect(layout.button, fx, fy) && m_tab != nullptr) + if (kind == EdgePanelKind::CENTER && layout.edit.Width > 0.0f && + UiPointInRect(layout.edit, 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; + // 中心轴面板底部编辑行 → 就地编辑平滑点数 N。 + const Lane& tabLane = m_tab->Lanes()[static_cast(index)]; + BeginEdit(PlotEditTarget::EDGE_CENTER_SMOOTH, index, + std::to_wstring(tabLane.edgeCenterSmooth), layout.edit); return true; } @@ -3664,6 +3879,11 @@ namespace jdi break; case PlotDragMode::CURSOR_SPAN_MOVE: break; + case PlotDragMode::LANE_REORDER: + // 网格次序拖拽结束:真正重排并重编号(GX 徽标随之同步)。 + FinishLaneReorder(); + changed = true; + break; case PlotDragMode::MOVE_SIGNAL: { const int target = LaneAtY(y); @@ -4268,6 +4488,16 @@ namespace jdi g.DrawRectangle(&borderPen, m_plotRect.X, m_plotRect.Y, m_plotRect.Width, m_plotRect.Height); }; + // 网格次序拖拽:静态场景缓存仍然有效,但各网格要按位移重新"摆放"。 + // 这里逐网格把缓存中的横向条带平移贴回(几十微秒), + // 因此拖动百万级采样的网格时依然流畅,且被拖网格的波形随鼠标自由移动。 + if (m_dragMode == PlotDragMode::LANE_REORDER && m_laneDragFrom >= 0 && m_sceneValid) + { + PaintLaneReorder(g); + paintOverlays(); + return; + } + // 静态场景复用(不再局限于拖拽):只要视图几何、信号集合与显示样式都没变, // "绘图区底色 + 全部网格 + 时间轴"就逐像素相同,直接贴图即可。 // 大文件下整幅重绘约 80ms,贴图约 1ms —— 鼠标悬停、拖拽选区这些 @@ -4548,9 +4778,17 @@ namespace jdi // 「边沿计数(中心轴)」的开关与平滑点数会改变网格内的曲线绘制 → 参与指纹。 mix(Tab().Lanes()[laneIndex].edgeCenter ? 1ull : 0ull); mix(static_cast(Tab().Lanes()[laneIndex].edgeCenterSmooth)); - // 「均值滤波」的开关与均值点数同样会改变网格内的曲线绘制 → 参与指纹。 + // 「均值滤波」的开关、每个被勾选信号各自的均值点数 X 与阶数 N 同样会改变 + // 网格内的曲线绘制 → 参与指纹(面板高度也随勾选数量变化)。 mix(Tab().Lanes()[laneIndex].meanFilter ? 1ull : 0ull); - mix(static_cast(Tab().Lanes()[laneIndex].meanFilterRadius)); + mix(static_cast(Tab().Lanes()[laneIndex].meanFilterItems.size())); + for (const Lane::MeanFilterItem& item : Tab().Lanes()[laneIndex].meanFilterItems) + { + mix(static_cast(item.ref.fileIndex)); + mix(static_cast(item.ref.signalIndex)); + mix(static_cast(item.points)); + mix(static_cast(item.order)); + } mix(signals.size()); for (const SignalRef& ref : signals) { @@ -4767,6 +5005,219 @@ namespace jdi CollectSceneStyles(m_sceneStyles); } + gd::RectF PlotView::LaneBadgeRect(int laneIndex) const + { + if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) + { + return gd::RectF(); + } + const LaneVisual& lane = m_lanes[static_cast(laneIndex)]; + const gd::RectF legendBand(lane.rect.X + kLegendPad * m_scale, lane.rect.Y + kLegendPad * m_scale, + lane.rect.Width, kLegendHeight * m_scale); + // 网格被滚动出可视区时没有徽标可点。 + const float visibleTop = std::max(lane.rect.Y, m_plotRect.Y); + const float visibleBottom = + std::min(lane.rect.Y + lane.rect.Height, m_plotRect.Y + m_plotRect.Height); + if (!IsFullyVisible(legendBand, visibleTop, visibleBottom)) + { + return gd::RectF(); + } + + const float height = kLegendHeight * m_scale; + const float badgeHeight = kLaneBadgeHeight * m_scale; + return gd::RectF(legendBand.X, legendBand.Y + (height - badgeHeight) * 0.5f, + kLaneBadgeWidth * m_scale, badgeHeight); + } + + int PlotView::LaneBadgeAtPoint(int x, int y) const + { + const float fx = static_cast(x); + const float fy = static_cast(y); + for (std::size_t index = 0; index < m_lanes.size(); ++index) + { + const gd::RectF badge = LaneBadgeRect(static_cast(index)); + if (badge.Width > 0.0f && UiPointInRect(badge, fx, fy)) + { + return static_cast(index); + } + } + return -1; + } + + float PlotView::LaneDragOffset(int laneIndex) const + { + if (m_dragMode != PlotDragMode::LANE_REORDER || m_laneDragFrom < 0 || + laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) + { + return 0.0f; + } + + const float laneHeight = m_lanes[static_cast(m_laneDragFrom)].rect.Height; + if (laneIndex == m_laneDragFrom) + { + // 被拖拽的网格自由跟随鼠标(整体绘制区域跟着走)。 + const float baseTop = m_lanes[static_cast(laneIndex)].rect.Y; + return static_cast(m_laneDragMouseY) - m_laneDragGrabOffset - baseTop; + } + + const int target = (m_laneDragTarget >= 0) ? m_laneDragTarget : m_laneDragFrom; + if (target > m_laneDragFrom && laneIndex > m_laneDragFrom && laneIndex < target) + { + // 向下拖:被跨越的网格整体上移一格让出位置(大小不变)。 + // 移动到插入位置 target 的结果是 [1,2,...,A,target,...], + // 因此需要上移的是 (from, target) 区间内的网格,不含 target 本身。 + return -laneHeight; + } + if (target < m_laneDragFrom && laneIndex >= target && laneIndex < m_laneDragFrom) + { + // 向上拖:中间的网格整体下移一格。 + return laneHeight; + } + return 0.0f; + } + + int PlotView::LaneDragTargetAt(int mouseY) const + { + if (m_lanes.empty()) + { + return 0; + } + const int count = static_cast(m_lanes.size()); + const float laneHeight = m_lanes.front().rect.Height; + if (!(laneHeight > 0.0f)) + { + return m_laneDragFrom; + } + + // 以"被拖网格的中心"为判据:中心越过某条网格的中线即认为目标变为该位置。 + const float centerY = static_cast(mouseY) - m_laneDragGrabOffset + laneHeight * 0.5f; + const float firstTop = m_lanes.front().rect.Y; + int index = static_cast(std::floor((centerY - firstTop) / laneHeight + 0.5f)); + index = std::max(0, std::min(index, count)); + return index; + } + + void PlotView::FinishLaneReorder() + { + const int from = m_laneDragFrom; + const int target = m_laneDragTarget; + m_laneDragFrom = -1; + m_laneDragTarget = -1; + m_laneDragGrabOffset = 0.0f; + m_dragMode = PlotDragMode::NONE; + + if (m_tab == nullptr || from < 0 || target < 0) + { + return; + } + if (!m_tab->MoveLane(from, target)) + { + return; + } + // 网格次序变化 → 几何、派生缓存与静态场景缓存全部失效。 + BuildLaneVisuals(); + InvalidateDerivedCaches(); + } + + void PlotView::PaintLaneReorder(gd::Graphics& g) + { + if (m_dragMode != PlotDragMode::LANE_REORDER || m_laneDragFrom < 0 || m_sceneDc == nullptr || + m_lanes.empty()) + { + return; + } + + const float laneHeight = m_lanes.front().rect.Height; + if (!(laneHeight > 0.0f)) + { + return; + } + + // 1) 先把"网格区域"整片铺成绘图区底色:让位留下的空档就是它的原色, + // 网格线/波形随后按各自位移贴回。 + UiFillRect(g, m_plotRect, m_theme->cardBg); + + HDC target = g.GetHDC(); + if (target == nullptr) + { + return; + } + + // 逐网格把缓存中该网格的横向条带按位移贴到屏幕上(非常廉价,整幅波形不必重绘)。 + // 被拖拽的网格最后贴,因此始终压在最上层,形成"被拎起来"的观感。 + std::vector order; + for (int index = 0; index < static_cast(m_lanes.size()); ++index) + { + if (index != m_laneDragFrom) + { + order.push_back(index); + } + } + if (m_laneDragFrom < static_cast(m_lanes.size())) + { + order.push_back(m_laneDragFrom); + } + + for (const int index : order) + { + const gd::RectF& rect = m_lanes[static_cast(index)].rect; + const float offset = LaneDragOffset(index); + // 源:缓存中该网格的条带;目标:屏幕上的同一条带平移 offset。 + const float sourceTop = rect.Y; + const float destTop = rect.Y + offset; + // 与绘图区求交(并夹进缓存区域),避免把波形贴到工具栏或时间轴上。 + const float clipTop = std::max(std::max(destTop, m_plotRect.Y), m_plotRect.Y); + const float clipBottom = + std::min(std::min(destTop + rect.Height, m_plotRect.Y + m_plotRect.Height), + m_plotRect.Y + m_plotRect.Height); + if (clipBottom - clipTop < 1.0f) + { + continue; + } + + const int x = static_cast(std::floor(rect.X)); + const int width = static_cast(std::ceil(rect.X + rect.Width)) - x; + const int y0 = static_cast(std::ceil(clipTop)); + const int y1 = static_cast(std::floor(clipBottom)); + if (y1 <= y0 || width <= 0) + { + continue; + } + + const int sourceY = static_cast(std::lround(sourceTop + (static_cast(y0) - destTop))); + BitBlt(target, x, y0, width, y1 - y0, m_sceneDc, x - m_sceneX, sourceY - m_sceneY, SRCCOPY); + } + g.ReleaseHDC(target); + + // 2) 目标位置的高亮提示(吸附提示):在被拖网格将要落到的槽位上画一层强调色底 + 边框。 + const int dropSlot = (m_laneDragTarget >= 0) ? m_laneDragTarget : m_laneDragFrom; + float slotTop = m_lanes.front().rect.Y + static_cast(dropSlot) * laneHeight; + if (dropSlot > m_laneDragFrom) + { + slotTop -= laneHeight; + } + slotTop = std::max(slotTop, m_plotRect.Y); + const gd::RectF slot(m_plotRect.X, slotTop, m_plotRect.Width, + std::min(laneHeight, m_plotRect.Y + m_plotRect.Height - slotTop)); + if (slot.Height > 0.0f) + { + UiFillRect(g, slot, WithAlpha(m_theme->accent, 0x1E)); + UiDrawRoundRect(g, gd::RectF(slot.X + 1.0f * m_scale, slot.Y + 1.0f * m_scale, + slot.Width - 2.0f * m_scale, slot.Height - 2.0f * m_scale), + 3.0f * m_scale, m_theme->accent, 1.6f * m_scale); + } + + // 3) 被拖网格的轮廓:加强"整块网格被拎起"的观感。 + const gd::RectF& dragged = m_lanes[static_cast(m_laneDragFrom)].rect; + const float destTop = dragged.Y + LaneDragOffset(m_laneDragFrom); + const gd::RectF outline(dragged.X + 1.0f * m_scale, destTop + 1.0f * m_scale, + dragged.Width - 2.0f * m_scale, dragged.Height - 2.0f * m_scale); + UiFillRoundRect(g, gd::RectF(outline.X + 2.0f * m_scale, outline.Y + 3.0f * m_scale, + outline.Width, outline.Height), + 3.0f * m_scale, 0x22000000u); + UiDrawRoundRect(g, outline, 3.0f * m_scale, m_theme->accent, 1.8f * m_scale); + } + bool PlotView::EnsureSceneCache(int width, int height) { if (width <= 0 || height <= 0) @@ -5106,11 +5557,9 @@ 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; + // 「均值滤波」开启时,为**被勾选**的信号各叠加一条多阶滑动平均曲线 + //(均值点数 X、阶数 N 由该信号自己的条目给出)。 + const bool meanFilterOn = Tab().Lanes()[static_cast(laneIndex)].meanFilter; for (const SignalRef& ref : laneSignals) { @@ -5230,10 +5679,16 @@ namespace jdi { PaintCenterCurve(g, resolved, lane, centerSmooth); } - // 均值滤波曲线(「实验性功能 → 均值滤波」):同样是仅用于预览的叠加曲线。 - if (meanFilterRadius >= 0) + // 均值滤波曲线(「实验性功能 → 均值滤波」):同样是仅用于预览的叠加曲线, + // 但**只有该信号被勾选时**才绘制(参数取自它自己的条目)。 + if (meanFilterOn) { - PaintMeanFilterCurve(g, resolved, lane, meanFilterRadius); + const Lane::MeanFilterItem* item = + Tab().Lanes()[static_cast(laneIndex)].FindMeanFilterItem(ref); + if (item != nullptr) + { + PaintMeanFilterCurve(g, resolved, lane, item->points, item->order); + } } } @@ -5355,9 +5810,9 @@ namespace jdi const float maxX = lane.rect.X + lane.rect.Width - kLanePadding * m_scale; // 网格编号徽标(图例左侧):与左侧信号面板每行末尾的 “GX” 徽标样式完全一致。 - const float badgeWidth = kLaneBadgeWidth * m_scale; + // 矩形由 LaneBadgeRect 统一计算,绘制与命中测试因此永远一致。 + const gd::RectF badgeRect = LaneBadgeRect(laneIndex); const float badgeHeight = kLaneBadgeHeight * m_scale; - const gd::RectF badgeRect(cursorX, top + (height - badgeHeight) * 0.5f, badgeWidth, badgeHeight); cursorX = badgeRect.X + badgeRect.Width + kLaneBadgeGap * m_scale; bool anyVisible = false; @@ -5398,8 +5853,15 @@ namespace jdi } // 编号徽标绘制在底板之上(与信号面板的 GX 徽标使用同一套配色与圆角)。 - UiFillRoundRect(g, badgeRect, badgeHeight * 0.5f, m_theme->cardBg); - UiDrawRoundRect(g, badgeRect, badgeHeight * 0.5f, m_theme->border, 1.0f); + // 拖拽重排时把被拖网格的徽标高亮,给出明确的"已抓住"反馈。 + const bool badgeActive = (m_dragMode == PlotDragMode::LANE_REORDER && m_laneDragFrom == laneIndex); + const bool badgeHover = (m_dragMode == PlotDragMode::NONE && m_hoverLane == laneIndex && + UiPointInRect(badgeRect, static_cast(m_mouseX), + static_cast(m_mouseY))); + UiFillRoundRect(g, badgeRect, badgeHeight * 0.5f, + (badgeActive || badgeHover) ? WithAlpha(m_theme->accent, 0x4A) : m_theme->cardBg); + UiDrawRoundRect(g, badgeRect, badgeHeight * 0.5f, + (badgeActive || badgeHover) ? m_theme->accent : m_theme->border, 1.0f); UiText(g, L"G" + std::to_wstring(laneIndex + 1), m_fonts->monoSmall, badgeRect, m_theme->text, TextAlign::CENTER); @@ -5825,15 +6287,15 @@ namespace jdi // 「边沿计数(中心轴)」/「均值滤波」开启时,纵向光标还要额外标注**派生曲线** // 在该时刻的取值(它不是信号,因此单独标注,不并入上面的取值文案)。 // 两条曲线的标注规则完全一致,故共用同一段绘制逻辑。 - const auto paintDerivedCapsule = [&](int smooth, const std::wstring& suffix, + const auto paintDerivedCapsule = [&](int first, int second, const std::wstring& suffix, bool meanFilter) { double derivedValue = 0.0; - const bool ok = meanFilter - ? MeanFilterValueAt(*resolved.file, *resolved.data, smooth, - time, derivedValue) - : CenterValueAt(*resolved.file, *resolved.data, smooth, time, - derivedValue); + const bool ok = + meanFilter ? MeanFilterValueAt(*resolved.file, *resolved.data, first, second, + time, derivedValue) + : CenterValueAt(*resolved.file, *resolved.data, first, time, + derivedValue); if (!ok) { return; @@ -5855,9 +6317,9 @@ namespace jdi double otherDerived = 0.0; const bool otherOk = meanFilter - ? MeanFilterValueAt(*resolved.file, *resolved.data, smooth, + ? MeanFilterValueAt(*resolved.file, *resolved.data, first, second, otherTime, otherDerived) - : CenterValueAt(*resolved.file, *resolved.data, smooth, otherTime, + : CenterValueAt(*resolved.file, *resolved.data, first, otherTime, otherDerived); if (otherOk) { @@ -5904,11 +6366,18 @@ namespace jdi if (m_tab->Lanes()[laneIndex].edgeCenter) { - paintDerivedCapsule(m_tab->Lanes()[laneIndex].edgeCenterSmooth, L"中心轴", false); + paintDerivedCapsule(m_tab->Lanes()[laneIndex].edgeCenterSmooth, 0, + L"中心轴", false); } if (m_tab->Lanes()[laneIndex].meanFilter) { - paintDerivedCapsule(m_tab->Lanes()[laneIndex].meanFilterRadius, L"均值滤波", true); + // 只标注被勾选(即真正绘制了预览曲线)的信号。 + const Lane::MeanFilterItem* meanItem = + m_tab->Lanes()[laneIndex].FindMeanFilterItem(ref); + if (meanItem != nullptr) + { + paintDerivedCapsule(meanItem->points, meanItem->order, L"均值滤波", true); + } } } } @@ -6008,8 +6477,35 @@ namespace jdi (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; + + // 均值滤波面板:每个条目 = 第一排(信号名 + 复选框)+ 第二排(均值点数 X / 阶数 N), + // 第二排**只在该条目被勾选时才占位**,因此面板高度随勾选数量动态变化。 + float meanRowHeight = 0.0f; + float height = 0.0f; + if (meanFilter) + { + meanRowHeight = kEdgePanelMeanRowHeight * m_scale; + float itemsHeight = 0.0f; + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || !resolved.visible) + { + continue; + } + itemsHeight += meanRowHeight; + if (lane.MeanFilterEnabled(ref)) + { + itemsHeight += (kEdgePanelMeanEditHeight + kEdgePanelMeanRowGap) * m_scale; + } + } + height = titleHeight + pad + itemsHeight + pad + buttonHeight; + } + else + { + height = titleHeight + pad + static_cast(rowCount) * rowHeight * rowsPerSignal + pad + + editHeight; + } // 首次使用(位置为负)时自动放到绘图区右侧,并各网格依次错开,避免完全重叠。 const double storedX = meanFilter ? lane.meanFilterPanelX @@ -6047,6 +6543,46 @@ namespace jdi const float buttonRowHeight = kEdgePanelButtonHeight * m_scale; layout.button = gd::RectF(left + pad, top + height - buttonRowHeight, std::max(0.0f, width - pad * 2.0f), buttonRowHeight); + + // 逐信号条目:第一排 = 名称 + 复选框(复选框靠右); + // 第二排 = 「均值点数 X」与「阶数 N」两个输入框(仅勾选时存在)。 + const float checkSize = kEdgePanelMeanCheckSize * m_scale; + const float fieldHeight = kEdgePanelMeanEditHeight * m_scale; + const float halfWidth = std::max(40.0f, (width - pad * 2.0f - 8.0f * m_scale) * 0.5f); + float rowY = top + titleHeight + pad; + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || !resolved.visible) + { + continue; + } + + layout.rowRefs.push_back(ref); + layout.rowChecks.push_back(gd::RectF( + left + width - pad - checkSize, rowY + (meanRowHeight - checkSize) * 0.5f, checkSize, + checkSize)); + const bool checked = lane.MeanFilterEnabled(ref); + if (checked) + { + const float editTop = rowY + meanRowHeight; + layout.rowPoints.push_back( + gd::RectF(left + pad, editTop, halfWidth, fieldHeight)); + layout.rowOrder.push_back( + gd::RectF(left + pad + halfWidth + 8.0f * m_scale, editTop, halfWidth, fieldHeight)); + } + else + { + layout.rowPoints.push_back(gd::RectF()); + layout.rowOrder.push_back(gd::RectF()); + } + + rowY += meanRowHeight; + if (checked) + { + rowY += (kEdgePanelMeanEditHeight + kEdgePanelMeanRowGap) * m_scale; + } + } } return layout; } @@ -6265,13 +6801,23 @@ namespace jdi void PlotView::PaintCenterCurve(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane, int smooth) const - { if (resolved.data == nullptr || resolved.file == nullptr || resolved.style == nullptr) + { + if (resolved.data == nullptr || resolved.file == nullptr || resolved.style == nullptr) + { + return; + } + PaintDerivedCurve(g, resolved, lane, CenterCurveFor(*resolved.data, smooth)); + } + + void PlotView::PaintDerivedCurve(gd::Graphics& g, const ResolvedSignal& resolved, + const LaneVisual& lane, const std::vector& curve) const + { + if (resolved.data == nullptr || resolved.file == nullptr || resolved.style == nullptr) { return; } const std::vector& time = resolved.file->time; - const std::vector& curve = CenterCurveFor(*resolved.data, smooth); const std::size_t count = std::min(time.size(), curve.size()); if (count == 0 || m_plotRect.Width <= 1.0f) { @@ -6370,11 +6916,15 @@ namespace jdi } void PlotView::PaintMeanFilterCurve(gd::Graphics& g, const ResolvedSignal& resolved, - const LaneVisual& lane, int radius) const + const LaneVisual& lane, int points, int order) const { - // 均值滤波曲线与「边沿计数(中心轴)」的中心平均值曲线是**同一算法、同一视觉效果** - // (同色相高对比度的派生配色 + 同透明度),因此直接复用同一段绘制逻辑。 - PaintCenterCurve(g, resolved, lane, radius); + if (resolved.data == nullptr || resolved.file == nullptr || resolved.style == nullptr) + { + return; + } + // 与「边沿计数(中心轴)」的中心平均值曲线共用同一段绘制逻辑 + // (同色相高对比度的派生配色 + 同透明度),只是曲线本身按多阶滤波计算。 + PaintDerivedCurve(g, resolved, lane, MeanFilterCurveFor(*resolved.data, points, order)); } void PlotView::PaintEdgeCursor(gd::Graphics& g, const std::vector& lanes) @@ -6519,7 +7069,84 @@ namespace jdi 1.6f * m_scale); // 每个信号两行:名称(信号色)+ 上升沿/下降沿数量; - // 均值滤波面板只需列出被处理的信号名(一行)。 + // 均值滤波面板改为「逐条目」:第一排 = 名称 + 复选框, + // 第二排 = 均值点数 X / 阶数 N(仅勾选的条目才有第二排)。 + if (meanFilter) + { + for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) + { + const SignalRef& ref = layout.rowRefs[rowIndex]; + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || resolved.style == nullptr) + { + continue; + } + const std::uint32_t signalColor = StyleColor(*resolved.style); + const gd::RectF& checkRect = layout.rowChecks[rowIndex]; + const bool checked = tabLane.MeanFilterEnabled(ref); + + // 名称:第一排左侧,避开右侧复选框。 + const float nameWidth = + std::max(20.0f, checkRect.X - (panel.X + pad) - 6.0f * m_scale); + UiText(g, UiEllipsize(resolved.data->Title(), m_fonts->uiSmall, nameWidth), + m_fonts->uiSmall, + gd::RectF(panel.X + pad, checkRect.Y - 3.0f * m_scale, nameWidth, + checkRect.Height + 6.0f * m_scale), + signalColor, TextAlign::LEFT); + UiDrawCheckBox(g, checkRect, checked ? 1 : 0, 3.0f * m_scale); + + if (!checked) + { + continue; + } + + // 第二排:两个输入框,左侧标“均值点数 X”,右侧标“阶数 N”。 + const Lane::MeanFilterItem* item = tabLane.FindMeanFilterItem(ref); + const int shownPoints = (item != nullptr) ? item->points : 5; + const int shownOrder = (item != nullptr) ? item->order : 1; + const auto paintField = [&](const gd::RectF& rect, const wchar_t* label, + int value, PlotEditTarget target) + { + if (rect.Width <= 0.0f) + { + return; + } + const float labelWidth = UiMeasureTextWidth(m_fonts->uiSmall, label); + UiText(g, label, m_fonts->uiSmall, + gd::RectF(rect.X, rect.Y, labelWidth, rect.Height), m_theme->textMuted, + TextAlign::LEFT); + + const float fieldWidth = + std::max(kEdgePanelEditFieldWidth * m_scale * 0.75f, + UiMeasureTextWidth(m_fonts->monoSmall, std::to_wstring(value)) + + 18.0f * m_scale); + const float fieldLeft = rect.X + rect.Width - fieldWidth; + const float fieldHeight = rect.Height - 4.0f * m_scale; + const gd::RectF fieldRect(fieldLeft, rect.Y + 2.0f * m_scale, fieldWidth, + fieldHeight); + + const bool editingThis = (m_editTarget == target) && + (m_editLane == static_cast(index)) && + (m_editMeanRef == ref); + if (editingThis) + { + UiDrawEditField(g, fieldRect, m_edit, m_fonts->monoSmall, m_scale); + } + else + { + 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(value), m_fonts->monoSmall, fieldRect, + m_theme->textStrong, TextAlign::CENTER); + } + }; + paintField(layout.rowPoints[rowIndex], L"均值点数 X", shownPoints, + PlotEditTarget::MEAN_FILTER_POINTS); + paintField(layout.rowOrder[rowIndex], L"阶数 N", shownOrder, + PlotEditTarget::MEAN_FILTER_ORDER); + } + } + float rowY = panel.Y + titleHeight + pad; for (const SignalRef& ref : tabLane.signals) { @@ -6528,17 +7155,17 @@ namespace jdi { continue; } + if (meanFilter) + { + // 均值滤波的逐条目绘制已在上面完成。 + continue; + } const std::uint32_t signalColor = StyleColor(*resolved.style); const float textWidth = panel.Width - pad * 2.0f; // 名称与统计数字一律完整绘制(不省略):面板宽度已按文本实测宽度确定。 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; @@ -6561,15 +7188,14 @@ namespace jdi rowY += rowHeight * 2.0f; } - // 中心轴 / 均值滤波面板底部固定一行参数编辑设置(始终位于面板最下方)。 - if (centerAxis || meanFilter) + // 中心轴面板底部固定一行参数编辑设置(始终位于面板最下方)。 + if (centerAxis) { const gd::RectF& editRect = layout.edit; if (editRect.Width > 0.0f) { - const int shownValue = meanFilter ? tabLane.meanFilterRadius - : tabLane.edgeCenterSmooth; - const std::wstring label = meanFilter ? L"均值点数 N" : L"平滑点数 N"; + const int shownValue = tabLane.edgeCenterSmooth; + const std::wstring label = 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), @@ -6584,10 +7210,8 @@ namespace jdi 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 == editTarget && m_editLane == static_cast(index)); + const bool editingThis = (m_editTarget == PlotEditTarget::EDGE_CENTER_SMOOTH && + m_editLane == static_cast(index)); if (editingThis) { UiDrawEditField(g, fieldRect, m_edit, m_fonts->monoSmall, m_scale); diff --git a/Utilities/Inc/ColorPickerPopup.hpp b/Utilities/Inc/ColorPickerPopup.hpp index c2587a5..9971a50 100644 --- a/Utilities/Inc/ColorPickerPopup.hpp +++ b/Utilities/Inc/ColorPickerPopup.hpp @@ -254,9 +254,9 @@ namespace jdi double CurrentHue() const; /** - * @brief RGB 分量变化后刷新记忆色相(仅在饱和度足够大时更新)。 + * @brief RGB 分量变化后刷新派生的色相与饱和度/明度选择状态。 */ - void RefreshHueFromChannels(); + void RefreshSelectionFromChannels(); /** * @brief 销毁色域方块缓存位图(尺寸或色相变化时重建)。 @@ -281,6 +281,14 @@ namespace jdi DragTarget m_drag = DragTarget::NONE; /**< 正在拖拽的控件。 */ std::array m_channels = {0.0, 0.0, 0.0}; /**< RGB 分量(0-1,唯一数据源)。 */ double m_hue = 0.0; /**< 记忆色相(0-1);灰度/黑色时保留上次取值。 */ + /** + * @brief 色域方块上的选点位置(饱和度/明度,均为 0-1)。 + * + * 必须**与 RGB 分开保存**:黑色/灰色的饱和度无定义,若每帧都从 RGB 反推, + * 把选点拖到方块右边界之外(明度归零)时选点会瞬间弹回最左侧。 + */ + double m_sat = 0.0; + double m_val = 0.0; gd::RectF m_rect; /**< 面板整体矩形。 */ gd::RectF m_titleRect; /**< 标题区域。 */ diff --git a/Utilities/Inc/DataModel.hpp b/Utilities/Inc/DataModel.hpp index e4ed70e..e962209 100644 --- a/Utilities/Inc/DataModel.hpp +++ b/Utilities/Inc/DataModel.hpp @@ -355,6 +355,19 @@ namespace jdi */ struct Lane { + /** + * @brief 「均值滤波」中某个信号自己的参数。 + * + * 仅参与列表中的信号才会被勾选预览;参数按信号保存, + * 因此同一条网格内不同信号可以使用不同的均值点数与阶数。 + */ + struct MeanFilterItem + { + SignalRef ref; /**< 目标信号。 */ + int points = 5; /**< 均值点数 X(滑平半径,窗口 = 2X+1)。 */ + int order = 1; /**< 阶数 N(重复滤波次数,至少 1)。 */ + }; + std::wstring name; /**< 网格名称。 */ std::vector signals; /**< 该网格包含的信号(按加入顺序)。 */ bool autoRange = true; /**< 是否依据信号取值范围自动计算 Y 轴。 */ @@ -416,14 +429,33 @@ namespace jdi /** * @brief 「实验性功能 → 均值滤波」开关。 * - * 开启后为本网格内每条信号额外绘制一条 N 点滑动平均曲线(算法与「边沿计数(中心轴)」 - * 的中心平均值曲线完全一致),并在浮动面板中提供参数 N 与「构建为新信号」按钮。 + * 开启后为本网格内**被勾选**的信号额外绘制一条多阶滑动平均曲线(按各自的均值点数 X + * 重复 N 阶),并在浮动面板中提供每个信号自己的参数与「构建为新信号」按钮。 * 与其它实验性功能一样属于网格自身状态,重建网格时按信号继承。 */ bool meanFilter = false; - /** 均值滤波半径 N(左右各取 N 个数据点,窗口大小 2N+1)。 */ - int meanFilterRadius = 5; + /** + * @brief 参与均值滤波预览的信号及其参数(只包含被勾选的信号)。 + * + * 顺序与信号在网格内的顺序不一定一致,绘制时按网格内信号顺序派发。 + */ + std::vector meanFilterItems; + + /** 取某个信号的均值滤波参数(未勾选时返回空指针)。 */ + const MeanFilterItem* FindMeanFilterItem(const SignalRef& ref) const; + + /** 取得或创建某信号的均值滤波参数(用于勾选)。 */ + MeanFilterItem& EnsureMeanFilterItem(const SignalRef& ref); + + /** 取消某信号的均值滤波勾选。 */ + void RemoveMeanFilterItem(const SignalRef& ref); + + /** 取某信号的均值滤波勾选状态。 */ + bool MeanFilterEnabled(const SignalRef& ref) const + { + return FindMeanFilterItem(ref) != nullptr; + } /** 均值滤波浮动面板是否展开。 */ bool meanFilterPanelOpen = true; @@ -462,6 +494,20 @@ namespace jdi */ void ComputeMovingAverage(const std::vector& values, int radius, std::vector& out); + /** + * @brief 计算多阶(迭代)滑动平均:把 N 点滑动平均重复应用 `order` 次。 + * + * 每次迭代以上一次的结果为输入(NaN 不参与平均),因此阶数越高曲线越平滑。 + * `order <= 1` 时与 `ComputeMovingAverage` 完全一致。 + * + * @param [IN] values 原始采样值。 + * @param [IN] radius 滑平半径 N(0 表示原样返回)。 + * @param [IN] order 阶数(迭代次数,至少为 1)。 + * @param [OUT] out 输出数组(长度与 values 相同)。 + */ + void ComputeMovingAverageIterated(const std::vector& values, int radius, int order, + std::vector& out); + /** * @brief 在指定文件的时间轴上查找与给定时刻最接近的采样点。 * @param [IN] file 目标文件。 @@ -663,6 +709,19 @@ namespace jdi */ bool MoveLaneOfSignalBelow(const SignalRef& moved, const SignalRef& anchor); + /** + * @brief 把某个网格整体移动到新的位置(用于拖拽 GX 编号调整网格次序)。 + * + * `target` 是**插入位置**(0..laneCount):0 表示移到最上方,laneCount 表示移到最下方。 + * 移动后会切换到自定义划分策略并按新位置重新编号全部网格, + * 因此所有以 GX 编号关联的界面(信号列表徽标、图例徽标、菜单、浮动面板)自动同步。 + * + * @param [IN] from 被移动的网格下标。 + * @param [IN] target 插入位置(0..网格数)。 + * @return 实际发生了移动返回 true。 + */ + bool MoveLane(int from, int target); + /** * @brief 按当前位置统一重命名全部网格(GX 编号由位置推导,须保持一致)。 */ diff --git a/Utilities/Src/ColorPickerPopup.cpp b/Utilities/Src/ColorPickerPopup.cpp index 91deabd..f8c4eba 100644 --- a/Utilities/Src/ColorPickerPopup.cpp +++ b/Utilities/Src/ColorPickerPopup.cpp @@ -23,8 +23,10 @@ namespace jdi const float kLabelWidth = 16.0f; /**< 通道标签宽度。 */ const float kLabelGap = 6.0f; /**< 标签与滑块间距。 */ const float kValueWidth = 40.0f; /**< 数值文本宽度。 */ - const float kTrackHeight = 6.0f; /**< 滑块轨道高度。 */ + const float kTrackHeight = 8.0f; /**< 滑块轨道高度。 */ const float kCorner = 6.0f; /**< 面板圆角半径。 */ + /** 色域方块 / 色相条 / 预览色块 / 色号框共用同一圆角(视觉上统一)。 */ + const float kSwatchCorner = 3.0f; const float kGamutSize = 112.0f; /**< 色域方块边长。 */ const float kHueWidth = 14.0f; /**< 色相条宽度。 */ const float kGamutGap = 10.0f; /**< 色域方块与色相条间距。 */ @@ -303,8 +305,8 @@ namespace jdi m_draggingSlider = -1; m_hoverSlider = -1; m_drag = DragTarget::NONE; - // 记住初始色相:之后即使把饱和度拖到 0(灰色)色相也不会丢失。 - m_hue = ToHsv(m_channels).h; + // 记住初始色相与选点位置:之后即使把明度拖到 0(黑色)色相/饱和度也不会丢失。 + RefreshSelectionFromChannels(); InvalidateGamutCache(); BuildLayout(bounds, anchor); @@ -330,19 +332,26 @@ namespace jdi double ColorPickerPopup::CurrentHue() const { - const Hsv hsv = ToHsv(m_channels); - // 灰度/黑色时 HSV 的色相没有定义:沿用记忆色相,避免拖动 RGB 滑块时色相跳变、 - // 也保证"饱和度归零后仍能靠色相条换回颜色"。 - return (hsv.s > 1.0e-4) ? hsv.h : m_hue; + // 直接返回保存的色相选择值(不再从 RGB 反推): + // 色相 1 与 0 是同一个颜色(红),反推会把它们归一到 0, + // 表现为"把滑块拖到最下面却弹回最上面"。 + // `RefreshSelectionFromChannels` 会在改色(RGB 滑块 / 色号)后同步这个值。 + return m_hue; } - void ColorPickerPopup::RefreshHueFromChannels() + void ColorPickerPopup::RefreshSelectionFromChannels() { const Hsv hsv = ToHsv(m_channels); + // 饱和度足够大时才认色相(灰度时色相无定义,沿用上次取值)。 if (hsv.s > 1.0e-4) { m_hue = hsv.h; } + // 饱和度/明度总是可以从 RGB 反推(明度=最大值,饱和度=max>0 ? delta/max : 0)。 + // 注意这是"由 RGB 派生"的路径,只用于外部改色(滑块/色号)后的同步; + // 直接用色域方块时不会走这里,以免黑色把饱和度重置为 0。 + m_sat = hsv.s; + m_val = hsv.v; } void ColorPickerPopup::InvalidateGamutCache() @@ -402,16 +411,18 @@ namespace jdi { return; } + // 越出方块时夹取到最近边界(而不是丢失位置):鼠标拖到方块之外时选点 + // 会停在对应角落,与鼠标相对方块的方位一致。 const double saturation = Clamp01((x - m_gamutRect.X) / m_gamutRect.Width); const double value = 1.0 - Clamp01((y - m_gamutRect.Y) / m_gamutRect.Height); - const std::array rgb = FromHsv(CurrentHue(), saturation, value); - if (std::fabs(rgb[0] - m_channels[0]) < 1.0e-6 && std::fabs(rgb[1] - m_channels[1]) < 1.0e-6 && - std::fabs(rgb[2] - m_channels[2]) < 1.0e-6) + if (std::fabs(saturation - m_sat) < 1.0e-9 && std::fabs(value - m_val) < 1.0e-9) { return; } - m_channels = rgb; - // 色相不变(明度归零时 FromHsv 会给出黑色,但记忆色相仍要保留)。 + // 先记录选点位置,再推颜色:两者分开保存,明度为 0 时饱和度不会因此丢失。 + m_sat = saturation; + m_val = value; + m_channels = FromHsv(CurrentHue(), m_sat, m_val); m_changed = true; } @@ -421,16 +432,14 @@ namespace jdi { return; } + // 越出色相条时同样夹取:拖到下方停在最下面(色相 1),不会因 0/1 等价而弹回顶部。 const double hue = Clamp01((y - m_hueRect.Y) / m_hueRect.Height); - const Hsv hsv = ToHsv(m_channels); - m_hue = hue; - const std::array rgb = FromHsv(m_hue, hsv.s, hsv.v); - if (std::fabs(rgb[0] - m_channels[0]) < 1.0e-6 && std::fabs(rgb[1] - m_channels[1]) < 1.0e-6 && - std::fabs(rgb[2] - m_channels[2]) < 1.0e-6) + if (std::fabs(hue - m_hue) < 1.0e-9) { return; } - m_channels = rgb; + m_hue = hue; + m_channels = FromHsv(m_hue, m_sat, m_val); m_changed = true; } @@ -549,7 +558,7 @@ namespace jdi m_channels[0] = static_cast((value >> 16) & 0xFFu) / 255.0; m_channels[1] = static_cast((value >> 8) & 0xFFu) / 255.0; m_channels[2] = static_cast(value & 0xFFu) / 255.0; - RefreshHueFromChannels(); + RefreshSelectionFromChannels(); m_changed = true; } } @@ -581,7 +590,7 @@ namespace jdi if (index >= 0 && index < 3) { m_channels[static_cast(index)] = value / 255.0; - RefreshHueFromChannels(); + RefreshSelectionFromChannels(); m_changed = true; } return true; @@ -730,7 +739,7 @@ namespace jdi return; } m_channels[slot] = ratio; - RefreshHueFromChannels(); + RefreshSelectionFromChannels(); m_changed = true; } @@ -920,7 +929,7 @@ namespace jdi { g.DrawImage(m_gamutCache.get(), m_gamutRect); } - UiDrawRoundRect(g, m_gamutRect, 2.0f * m_scale, m_theme->borderStrong, kGamutBorder); + UiDrawRoundRect(g, m_gamutRect, kSwatchCorner * m_scale, m_theme->border, kGamutBorder); // ---- 色相条(纵向 0-1):6 段线性渐变拼出完整色相环 ---- { @@ -945,12 +954,12 @@ namespace jdi // 渐变端点会各自向两侧延伸半段,逐段微重叠地填充即可无缝拼接。 g.FillRectangle(&brush, m_hueRect.X, top, m_hueRect.Width, bottom - top); } - UiDrawRoundRect(g, m_hueRect, 2.0f * m_scale, m_theme->border, kGamutBorder); + UiDrawRoundRect(g, m_hueRect, kSwatchCorner * m_scale, m_theme->border, kGamutBorder); // ---- 色域方块上的选点:中心就是当前选中的颜色,外面只加一圈白色描边 ---- - const Hsv hsv = ToHsv(m_channels); - const float markerX = m_gamutRect.X + static_cast(hsv.s) * m_gamutRect.Width; - const float markerY = m_gamutRect.Y + static_cast(1.0 - hsv.v) * m_gamutRect.Height; + // 位置直接用保存的选点状态(不再从 RGB 反推,否则黑色会让选点弹回左侧)。 + const float markerX = m_gamutRect.X + static_cast(m_sat) * m_gamutRect.Width; + const float markerY = m_gamutRect.Y + static_cast(1.0 - m_val) * m_gamutRect.Height; const float markerRadius = 5.5f * m_scale; gd::SolidBrush markerBrush(ToGdiColor(CurrentColor())); g.FillEllipse(&markerBrush, markerX - markerRadius, markerY - markerRadius, @@ -960,6 +969,8 @@ namespace jdi markerRadius * 2.0f, markerRadius * 2.0f); // ---- 色相条上的指示器:白色描边的圆角长方条,底色就是该位置的颜色 ---- + // 色相直接用保存值:色相 1 与 0 是同一个颜色(红),从 RGB 反推会归一到 0, + // 表现为"把滑块拖到最下面却弹回最上面"。 const double hue = CurrentHue(); const std::array hueRgb = FromHsv(hue, 1.0, 1.0); const std::uint32_t hueColor = @@ -999,8 +1010,8 @@ namespace jdi // 预览与十六进制值。 const std::uint32_t color = CurrentColor(); - UiFillRoundRect(g, m_previewRect, 3.0f * m_scale, color); - UiDrawRoundRect(g, m_previewRect, 3.0f * m_scale, m_theme->borderStrong, 1.0f); + UiFillRoundRect(g, m_previewRect, kSwatchCorner * m_scale, color); + UiDrawRoundRect(g, m_previewRect, kSwatchCorner * m_scale, m_theme->borderStrong, 1.0f); if (m_editField == EditField::HEX) { diff --git a/Utilities/Src/DataModel.cpp b/Utilities/Src/DataModel.cpp index e3fbcef..91f139f 100644 --- a/Utilities/Src/DataModel.cpp +++ b/Utilities/Src/DataModel.cpp @@ -127,7 +127,7 @@ namespace jdi double edgeCenterPanelX = -1.0; double edgeCenterPanelY = -1.0; bool meanFilter = false; - int meanFilterRadius = 5; + std::vector meanFilterItems; bool meanFilterPanelOpen = true; double meanFilterPanelX = -1.0; double meanFilterPanelY = -1.0; @@ -152,7 +152,7 @@ namespace jdi carry.edgeCenterPanelX = lane.edgeCenterPanelX; carry.edgeCenterPanelY = lane.edgeCenterPanelY; carry.meanFilter = lane.meanFilter; - carry.meanFilterRadius = lane.meanFilterRadius; + carry.meanFilterItems = lane.meanFilterItems; carry.meanFilterPanelOpen = lane.meanFilterPanelOpen; carry.meanFilterPanelX = lane.meanFilterPanelX; carry.meanFilterPanelY = lane.meanFilterPanelY; @@ -188,7 +188,7 @@ namespace jdi lane.edgeCenterPanelX = carry.edgeCenterPanelX; lane.edgeCenterPanelY = carry.edgeCenterPanelY; lane.meanFilter = carry.meanFilter; - lane.meanFilterRadius = carry.meanFilterRadius; + lane.meanFilterItems = carry.meanFilterItems; lane.meanFilterPanelOpen = carry.meanFilterPanelOpen; lane.meanFilterPanelX = carry.meanFilterPanelX; lane.meanFilterPanelY = carry.meanFilterPanelY; @@ -969,6 +969,67 @@ namespace jdi } } + void ComputeMovingAverageIterated(const std::vector& values, int radius, int order, + std::vector& out) + { + const int stages = std::max(1, order); + // 第 1 阶:从原始采样值出发。 + ComputeMovingAverage(values, radius, out); + if (stages == 1) + { + return; + } + + // 后续各阶以上一阶的结果为输入(double 中转,避免 float→double 的精度损失累积)。 + std::vector buffer(values.size(), std::numeric_limits::quiet_NaN()); + std::vector stage; + for (int iteration = 1; iteration < stages; ++iteration) + { + for (std::size_t index = 0; index < out.size(); ++index) + { + buffer[index] = std::isfinite(out[index]) ? static_cast(out[index]) + : std::numeric_limits::quiet_NaN(); + } + ComputeMovingAverage(buffer, radius, stage); + out.swap(stage); + } + } + + const Lane::MeanFilterItem* Lane::FindMeanFilterItem(const SignalRef& ref) const + { + for (const MeanFilterItem& item : meanFilterItems) + { + if (item.ref == ref) + { + return &item; + } + } + return nullptr; + } + + Lane::MeanFilterItem& Lane::EnsureMeanFilterItem(const SignalRef& ref) + { + for (MeanFilterItem& item : meanFilterItems) + { + if (item.ref == ref) + { + return item; + } + } + MeanFilterItem item; + item.ref = ref; + meanFilterItems.push_back(item); + return meanFilterItems.back(); + } + + void Lane::RemoveMeanFilterItem(const SignalRef& ref) + { + meanFilterItems.erase( + std::remove_if(meanFilterItems.begin(), meanFilterItems.end(), + [&ref](const MeanFilterItem& item) { return item.ref == ref; }), + meanFilterItems.end()); + } + int FindNearestSample(const DataFile& file, double time) { const std::size_t count = file.time.size(); @@ -1192,6 +1253,23 @@ namespace jdi --ref.signalIndex; } } + + // 均值滤波条目同样持有信号引用,必须一起删除/前移。 + std::vector& items = lane.meanFilterItems; + items.erase(std::remove_if(items.begin(), items.end(), + [fileIndex, signalIndex](const Lane::MeanFilterItem& item) + { + return item.ref.fileIndex == fileIndex && + item.ref.signalIndex == signalIndex; + }), + items.end()); + for (Lane::MeanFilterItem& item : items) + { + if (item.ref.fileIndex == fileIndex && item.ref.signalIndex > signalIndex) + { + --item.ref.signalIndex; + } + } } // 被移除信号若独占了某个网格,该网格随之消失(与取消勾选的行为一致)。 m_lanes.erase(std::remove_if(m_lanes.begin(), m_lanes.end(), @@ -1485,7 +1563,15 @@ namespace jdi lane.edgeCenterPanelX = source.edgeCenterPanelX; lane.edgeCenterPanelY = source.edgeCenterPanelY; lane.meanFilter = source.meanFilter; - lane.meanFilterRadius = source.meanFilterRadius; + // 拆分后每个子网格只保留自己那个信号的均值滤波条目。 + lane.meanFilterItems.clear(); + for (const Lane::MeanFilterItem& item : source.meanFilterItems) + { + if (item.ref == ref) + { + lane.meanFilterItems.push_back(item); + } + } lane.meanFilterPanelOpen = source.meanFilterPanelOpen; lane.meanFilterPanelX = source.meanFilterPanelX; lane.meanFilterPanelY = source.meanFilterPanelY; @@ -1529,6 +1615,14 @@ namespace jdi ++ref.signalIndex; } } + // 均值滤波条目同样持有信号引用,必须一起后移。 + for (Lane::MeanFilterItem& item : lane.meanFilterItems) + { + if (item.ref.fileIndex == fileIndex && item.ref.signalIndex >= insertPos) + { + ++item.ref.signalIndex; + } + } } } @@ -1560,6 +1654,37 @@ namespace jdi return true; } + bool TabConfig::MoveLane(int from, int target) + { + const int count = static_cast(m_lanes.size()); + if (from < 0 || from >= count || target < 0 || target > count) + { + return false; + } + + // `target` 语义 = 「插入到该下标之前」;向后拖时扣除自身占位后再比较, + // 保证"拖到自己原来的位置"不会产生无意义的移动。 + int destination = target; + if (destination > from) + { + --destination; + } + destination = std::max(0, std::min(destination, count - 1)); + if (destination == from) + { + return false; + } + + Lane moved = std::move(m_lanes[static_cast(from)]); + m_lanes.erase(m_lanes.begin() + from); + m_lanes.insert(m_lanes.begin() + destination, std::move(moved)); + + // 手动排布 → 切换到自定义划分策略,避免后续重建网格时顺序被还原。 + m_laneMode = LaneMode::CUSTOM; + RenameLanes(); + return true; + } + void TabConfig::RenameLanes() { for (std::size_t index = 0; index < m_lanes.size(); ++index)