From 2b02518f8592df4e146d5a14775c687a2f069500 Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Tue, 13 Oct 2026 14:50:38 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A0=B7=E5=BC=8F=E5=8C=BA=E3=80=8C=E8=B0=83?= =?UTF-8?q?=E6=95=B4=E3=80=8D=E8=A1=8C=E6=94=B9=E4=B8=BA=20=E7=B3=BB?= =?UTF-8?q?=E6=95=B0/=E9=95=9C=E5=83=8F/=E7=BB=9D=E5=AF=B9=E5=80=BC?= =?UTF-8?q?=EF=BC=9A=E7=B3=BB=E6=95=B0=E4=BA=8C=E7=BA=A7=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=EF=BC=88=E8=BF=98=E5=8E=9F=E5=9B=BE=E6=A0=87=20+=20=E6=95=B4?= =?UTF-8?q?=E8=A1=8C=E8=BE=93=E5=85=A5=E6=A1=86=EF=BC=89=E3=80=81=E9=95=9C?= =?UTF-8?q?=E5=83=8F=E4=BA=8C=E7=BA=A7=E9=9D=A2=E6=9D=BF=EF=BC=88=E6=B0=B4?= =?UTF-8?q?=E5=B9=B3/=E5=9E=82=E7=9B=B4=E5=A4=8D=E9=80=89=E6=A1=86?= =?UTF-8?q?=EF=BC=89=EF=BC=9BY=20=E8=BD=B4=E8=87=AA=E9=80=82=E5=BA=94?= =?UTF-8?q?=E6=8C=89=E5=8F=98=E6=8D=A2=E5=90=8E=E6=9B=B2=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 「调整」行三个按钮改为「系数」「镜像」「绝对值」(顺序即结算顺序:系数 → 绝对值 → 镜像)。 「系数」「镜像」点击后在按钮下沿展开二级面板(按下只登记、抬起命中同一按钮才展开, 与自定义颜色入口一致),「绝对值」仍是直接切换的按钮。 打开面板时主动清除样式区悬停态 —— 面板会接管鼠标事件,否则该按钮会一直保持"鼠标按住"般的高亮。 - 数据层:`SignalStyle::inverted` 拆成 `mirrorVertical`(Y=0 上下翻转)与 `mirrorHorizontal`(水平镜像 = 数据反序);新增 `Mirrored()`、 `SourceIndexFor`(水平镜像的遍历下标 → 源下标映射)、`TransformValueRange`(区间映射)。 `ApplyDrawTransform` 顺序固定为 系数 → 绝对值 → 垂直镜像;水平镜像在采样遍历层处理。 - 绘制链路:`BuildPolyline`(逐点描边与像素列包络两条路径、跨列边界插值、粗粒度分组层)、 派生预览曲线、光标取值读数(含 Δt)全部按同一变换取值;水平镜像时按源下标区间反序读取, 包络层仍复用粗粒度分组;折线缓存键、`SceneContentHash`、样式快照都带上两个镜像位。 - 系数二级面板:标题「系数调整」,标题栏最右端是一个可点击的「还原」自绘图标按钮 (悬停有高亮反馈,点击后系数重置为 1,面板保持打开);下面是一整行输入框 (不再显示「绘制系数」说明文字),数值至少显示两位小数、精度更高时按实际精度显示 (1 → 1.00、2.5 → 2.50、1.2345678 原样)。 - 镜像二级面板:两行带复选框的切换项「水平镜像」「垂直镜像」,点击后**面板保持打开** (`MenuItem::keepOpen`),勾选状态就地回写;任一项勾选时「镜像」按钮显示为激活态。 - Y 轴自适应修复:整段自适应(`TabConfig::ComputeLaneRange`)按区间映射(`TransformValueRange`) 计算,不再直接用原始 min/max;可见区间自适应(`AutoRangeLaneToView`)逐点套用绘制变换 (含水平镜像的源下标映射)。用户报告的"调整系数/镜像后自适应区域错误、仍按原始数据"由此修复。 - `PopupMenu`:新增 `MenuItemKind::HEADER_ACTION`(标题 + 右侧可点击图标,图标有悬停高亮)、 `MenuItem::keepOpen`(切换项点击后不关闭菜单)、`HeaderIconRect()`, 以及无说明文字的整行输入框(占满内容区)。 验证(离屏 harness,SW_HIDE、不动鼠标键盘屏幕): - [AL] 重写/扩充:变换顺序与区间映射(含水平镜像的源下标反序)、调整行三按钮的顺序/等宽/标签不裁切、 「系数」按住不弹出 / 移开取消 / 单击展开(标题 HEADER_ACTION、无标题输入框占满整行、初始 1.00、 两位小数与更高精度回显、提交后面板保持打开、折线缓存键携带系数)、 「还原」图标位置/悬停像素变化/点击后系数回到 1.00 且面板保持打开、 「镜像」面板两行复选框(初始未勾选、keepOpen、勾选后按钮激活、取消其一保留另一)、 打开面板后按钮不再停留高亮、系数/镜像按钮的激活填充与其它启用态切换按钮逐像素同色; `BuildPolyline` 顶点范围与变换后数据范围逐项对齐(默认/系数 2/绝对值/垂直镜像/三者叠加), 水平镜像用窄视窗逐顶点核对"取的是对面端点的采样"(8/8)+ 读数跟随; 以及 Y 轴自适应:整段/可见区间自适应在系数、绝对值、垂直镜像下均跟随变换后的曲线。 - 合计 594/594 通过;make clean && make all 零警告。 --- Components/PlotView/Inc/PlotView.hpp | 13 +- Components/PlotView/Src/PlotView.cpp | 81 ++++++--- Components/SignalPanel/Inc/SignalPanel.hpp | 38 ++++- Components/SignalPanel/Src/SignalPanel.cpp | 183 ++++++++++++++++----- Utilities/Inc/DataModel.hpp | 126 ++++++++++++-- Utilities/Inc/PopupMenu.hpp | 22 ++- Utilities/Src/DataModel.cpp | 88 +++++++++- Utilities/Src/PopupMenu.cpp | 167 ++++++++++++++++++- 8 files changed, 608 insertions(+), 110 deletions(-) diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 815a303..3fc81f5 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -1838,9 +1838,10 @@ namespace jdi float laneHeight = 0.0f; /**< 网格高度。 */ double laneYMin = 0.0; /**< 网格幅值下界。 */ double laneYMax = 0.0; /**< 网格幅值上界。 */ - float gain = 1.0f; /**< 绘制前的系数增益。 */ + float gain = 1.0f; /**< 绘制前的系数。 */ bool absolute = false; /**< 绘制前是否取绝对值。 */ - bool inverted = false; /**< 绘制前是否中心反转。 */ + bool mirrorVertical = false; /**< 绘制前是否按 Y=0 上下镜像。 */ + bool mirrorHorizontal = false; /**< 是否水平镜像(取值下标反序)。 */ std::vector points; /**< 折线顶点。 */ std::vector breaks; /**< 每段折线在 points 中的起始下标。 */ @@ -2004,9 +2005,10 @@ namespace jdi float opacity = 1.0f; /**< 不透明度。 */ float lineWidth = 1.0f; /**< 线宽。 */ int mode = 0; /**< 绘制模式。 */ - float gain = 1.0f; /**< 系数增益。 */ + float gain = 1.0f; /**< 系数。 */ bool absolute = false; /**< 是否取绝对值。 */ - bool inverted = false; /**< 是否中心反转。 */ + bool mirrorVertical = false; /**< 是否按 Y=0 上下镜像。 */ + bool mirrorHorizontal = false; /**< 是否水平镜像(数据反序)。 */ /** @brief 相等比较(用于判断"逐信号外观样式是否变化")。 */ bool operator==(const SceneStyleEntry& other) const @@ -2014,7 +2016,8 @@ namespace jdi return color == other.color && opacity == other.opacity && lineWidth == other.lineWidth && mode == other.mode && gain == other.gain && absolute == other.absolute && - inverted == other.inverted; + mirrorVertical == other.mirrorVertical && + mirrorHorizontal == other.mirrorHorizontal; } }; diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index 4cf1789..94058c1 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -205,13 +205,15 @@ namespace jdi * @param [IN] time 时间轴。 * @param [IN] values 采样值。 * @param [IN] count 可用采样点数量。 + * @param [IN] style 显示外观(水平镜像时按**源下标反序**取值)。 * @param [IN] firstIndex 第一个时间不小于查询时刻的采样下标(相交线段为 firstIndex-1 → firstIndex)。 * @param [IN] t 查询时刻。 * @param [OUT] out 输出取值。 * @return 折线覆盖该时刻且取到有效值返回 true。 */ bool PolylineValueAt(const std::vector& time, const std::vector& values, - std::size_t count, std::size_t firstIndex, double t, double& out) + std::size_t count, const SignalStyle& style, std::size_t firstIndex, + double t, double& out) { if (firstIndex == 0 || firstIndex >= count) { @@ -220,8 +222,9 @@ namespace jdi } const double t0 = time[firstIndex - 1]; const double t1 = time[firstIndex]; - const double v0 = values[firstIndex - 1]; - const double v1 = values[firstIndex]; + // 水平镜像时画面上的相邻两点取源数组中头尾对调后的两个采样。 + const double v0 = values[SourceIndexFor(style, count, firstIndex - 1)]; + const double v1 = values[SourceIndexFor(style, count, firstIndex)]; if (!std::isfinite(t0) || !std::isfinite(t1) || !std::isfinite(v0) || !std::isfinite(v1) || !(t1 > t0)) { @@ -3228,7 +3231,17 @@ namespace jdi continue; } } - const double value = signal->values[index]; + // ⚠️ 自适应必须基于**画出来的曲线**:套用与绘制完全相同的变换 + //(系数 → 绝对值 → 镜像),否则调整系数/镜像后自适应范围会按原始数据算错 + //(用户报告"自适应区域错误,看上去还是按照原始数据来的")。 + // 水平镜像时画面上的第 index 个位置取源下标 count-1-index 的值。 + const std::size_t sourceIndex = SourceIndexFor(*m_dataSet->Style(ref), count, index); + const double raw = signal->values[sourceIndex]; + if (!std::isfinite(raw)) + { + continue; + } + const double value = ApplyDrawTransform(*m_dataSet->Style(ref), raw); if (std::isfinite(value)) { low = std::min(low, value); @@ -5342,10 +5355,11 @@ namespace jdi const std::size_t sampleCount = std::min(resolved.file->time.size(), resolved.data->values.size()); const double viewMin = Tab().ViewMin(); const double viewMax = Tab().ViewMax(); - // 绘制前的数值变换(增益/绝对值/中心反转)会改变顶点,必须进缓存键。 + // 绘制前的数值变换(系数/绝对值/镜像)会改变顶点,必须进缓存键。 const float gain = (resolved.style != nullptr) ? resolved.style->gain : 1.0f; const bool absolute = (resolved.style != nullptr) && resolved.style->absolute; - const bool inverted = (resolved.style != nullptr) && resolved.style->inverted; + const bool mirrorVertical = (resolved.style != nullptr) && resolved.style->mirrorVertical; + const bool mirrorHorizontal = (resolved.style != nullptr) && resolved.style->mirrorHorizontal; for (const PolylineCacheEntry& entry : m_polylineCache) { @@ -5354,7 +5368,7 @@ namespace jdi entry.plotWidth == m_plotRect.Width && entry.laneY == lane.rect.Y && entry.laneHeight == lane.rect.Height && entry.laneYMin == lane.yMin && entry.laneYMax == lane.yMax && entry.gain == gain && entry.absolute == absolute && - entry.inverted == inverted) + entry.mirrorVertical == mirrorVertical && entry.mirrorHorizontal == mirrorHorizontal) { return &entry; } @@ -5381,7 +5395,8 @@ namespace jdi entry.laneYMax = lane.yMax; entry.gain = gain; entry.absolute = absolute; - entry.inverted = inverted; + entry.mirrorVertical = mirrorVertical; + entry.mirrorHorizontal = mirrorHorizontal; BuildPolyline(resolved, lane, entry.points, entry.breaks, entry.band, entry.bandBreaks, entry.bandMode, entry.bandSpiky); @@ -5584,11 +5599,16 @@ namespace jdi bool broken = false; // (一)落在本列区间内的采样点。 + // 水平镜像时取的是**源下标**(区间同样反转),因此先把列区间映射到源下标区间。 + const std::size_t srcA = std::min(SourceIndexFor(*resolved.style, count, i0), + SourceIndexFor(*resolved.style, count, (i1 > 0) ? (i1 - 1) : 0)); + const std::size_t srcB = std::max(SourceIndexFor(*resolved.style, count, i0), + SourceIndexFor(*resolved.style, count, (i1 > 0) ? (i1 - 1) : 0)); if (i1 > i0 && hasCoarse && (i1 - i0) >= 2 * coarseStride) { - const std::size_t firstBucket = i0 / coarseStride; + const std::size_t firstBucket = srcA / coarseStride; const std::size_t lastBucket = - std::min((i1 - 1) / coarseStride, coarseMin.size() - 1); + std::min(srcB / coarseStride, coarseMin.size() - 1); for (std::size_t bucket = firstBucket; bucket <= lastBucket; ++bucket) { const double bucketLow = coarseMin[bucket] * coarseScale; @@ -5613,7 +5633,7 @@ namespace jdi } else if (i1 > i0) { - for (std::size_t index = i0; index < i1; ++index) + for (std::size_t index = srcA; index <= srcB; ++index) { const double value = ApplyDrawTransform(*resolved.style, values[index]); if (!std::isfinite(value)) @@ -5647,32 +5667,33 @@ namespace jdi // 其极值必然出现在采样点或列边界上,因此「列内采样点 + 两侧边界取值」就是 // 该列包络的精确解,且相邻两列在边界处取到同一个值,包络带严丝合缝。 double boundary = 0.0; - if (PolylineValueAt(time, values, count, i0, t0, boundary)) + if (PolylineValueAt(time, values, count, *resolved.style, i0, t0, boundary)) { boundary = ApplyDrawTransform(*resolved.style, boundary); if (boundary < low) { low = boundary; - lowOrder = (i0 > 0) ? (i0 - 1) : 0; + lowOrder = (i0 > 0) ? SourceIndexFor(*resolved.style, count, i0 - 1) : 0; } if (boundary > high) { high = boundary; - highOrder = (i0 > 0) ? (i0 - 1) : 0; + highOrder = (i0 > 0) ? SourceIndexFor(*resolved.style, count, i0 - 1) : 0; } } - if (PolylineValueAt(time, values, count, i1, t1, boundary)) + if (PolylineValueAt(time, values, count, *resolved.style, i1, t1, boundary)) { boundary = ApplyDrawTransform(*resolved.style, boundary); + const std::size_t order = SourceIndexFor(*resolved.style, count, (i1 > 0) ? (i1 - 1) : 0); if (boundary < low) { low = boundary; - lowOrder = i1; + lowOrder = order; } if (boundary > high) { high = boundary; - highOrder = i1; + highOrder = order; } } @@ -5749,7 +5770,9 @@ namespace jdi for (std::size_t index = begin; index < end; index += stride) { const double t = time[index]; - const double value = ApplyDrawTransform(*resolved.style, values[index]); + // 水平镜像 = 数据反序:画面上第 index 个位置取源下标 count-1-index 的值。 + const double value = ApplyDrawTransform( + *resolved.style, values[SourceIndexFor(*resolved.style, count, index)]); if (!std::isfinite(t) || !std::isfinite(value)) { inSegment = false; @@ -6413,10 +6436,11 @@ namespace jdi mixFloat(resolved.style->opacity); mixFloat(resolved.style->lineWidth); mix(static_cast(resolved.style->mode)); - // 绘制前的数值变换(增益 / 取绝对值 / 中心反转)会改变波形形状。 + // 绘制前的数值变换(系数 / 绝对值 / 镜像)会改变波形形状。 mixFloat(resolved.style->gain); mix(resolved.style->absolute ? 1ull : 0ull); - mix(resolved.style->inverted ? 1ull : 0ull); + mix(resolved.style->mirrorVertical ? 1ull : 0ull); + mix(resolved.style->mirrorHorizontal ? 1ull : 0ull); } // 图例文案参与绘制,重命名必须让缓存失效。 mixText(resolved.data->Title()); @@ -6464,7 +6488,8 @@ namespace jdi entry.mode = static_cast(resolved.style->mode); entry.gain = resolved.style->gain; entry.absolute = resolved.style->absolute; - entry.inverted = resolved.style->inverted; + entry.mirrorVertical = resolved.style->mirrorVertical; + entry.mirrorHorizontal = resolved.style->mirrorHorizontal; } lane.push_back(entry); } @@ -9397,11 +9422,12 @@ namespace jdi const bool interpolate = (resolved.style->mode != DrawMode::SCATTER); double value = 0.0; - if (!SampleSignalAt(*resolved.file, *resolved.data, time, interpolate, value)) + if (!SampleDisplayedSignalAt(*resolved.file, *resolved.data, *resolved.style, time, + interpolate, value)) { continue; } - // 取值读数必须与画出来的波形一致:同样套用增益/取绝对值/中心反转。 + // 取值读数必须与画出来的波形一致:同样套用系数/绝对值/镜像。 value = ApplyDrawTransform(*resolved.style, value); const float rawY = ValueToY(value, lane); @@ -9423,7 +9449,8 @@ namespace jdi if (showDelta) { double otherValue = 0.0; - if (SampleSignalAt(*resolved.file, *resolved.data, otherTime, interpolate, otherValue)) + if (SampleDisplayedSignalAt(*resolved.file, *resolved.data, *resolved.style, otherTime, + interpolate, otherValue)) { otherValue = ApplyDrawTransform(*resolved.style, otherValue); const double delta = value - otherValue; @@ -10345,12 +10372,14 @@ namespace jdi for (std::size_t index = begin; index < end; index += stride) { - if (index < curveBegin || (index - curveBegin) >= curve.size()) + // 水平镜像 = 数据反序:遍历下标映射到源下标后再取曲线值。 + const std::size_t sourceIndex = SourceIndexFor(*resolved.style, count, index); + if (sourceIndex < curveBegin || (sourceIndex - curveBegin) >= curve.size()) { inSegment = false; continue; } - const float sampled = curve[index - curveBegin]; + const float sampled = curve[sourceIndex - curveBegin]; if (!std::isfinite(sampled)) { inSegment = false; diff --git a/Components/SignalPanel/Inc/SignalPanel.hpp b/Components/SignalPanel/Inc/SignalPanel.hpp index f2f6532..820d2e1 100644 --- a/Components/SignalPanel/Inc/SignalPanel.hpp +++ b/Components/SignalPanel/Inc/SignalPanel.hpp @@ -35,7 +35,10 @@ namespace jdi CMD_MODE_BASE = 200, /**< 绘制样式基准(配合下标)。 */ CMD_LANE_BASE = 300, /**< 网格归属基准(配合下标)。 */ CMD_LANE_NEW = 400, /**< 新建网格。 */ - CMD_GAIN_EDIT = 500, /**< 「系数调整」二级面板里的系数输入框(幂等提交)。 */ + CMD_GAIN_EDIT = 500, /**< 「系数」二级面板里的系数输入框(幂等提交)。 */ + CMD_GAIN_RESET = 501, /**< 「系数」二级面板标题栏右侧的还原按钮:把系数重置为 1。 */ + CMD_MIRROR_HORIZONTAL = 510, /**< 「镜像」二级面板:水平镜像(数据反序)。 */ + CMD_MIRROR_VERTICAL = 511 /**< 「镜像」二级面板:垂直镜像(按 Y=0 上下翻转)。 */ }; /** @@ -340,9 +343,9 @@ namespace jdi OPACITY_VALUE, /**< 不透明度数值框(可点击输入)。 */ WIDTH_SLIDER, /**< 线宽滑块。 */ WIDTH_VALUE, /**< 线宽数值框(可点击输入)。 */ - GAIN_ADJUST, /**< 「系数调整」按钮(点击展开系数输入二级面板)。 */ - ABSOLUTE_TOGGLE, /**< 「取绝对值」切换按钮。 */ - INVERT_TOGGLE, /**< 「中心反转」切换按钮。 */ + GAIN_ADJUST, /**< 「系数」按钮(点击展开系数二级面板)。 */ + MIRROR_ADJUST, /**< 「镜像」按钮(点击展开镜像二级面板)。 */ + ABSOLUTE_TOGGLE, /**< 「绝对值」切换按钮。 */ TOGGLE_ALL, /**< 全部显示/隐藏按钮。 */ FILTER_CLEAR /**< 「筛选信号」输入框右侧的清空 ✕。 */ }; @@ -494,16 +497,34 @@ namespace jdi bool OpenColorPickerPopup(const SignalStyle& style); /** - * @brief 打开「系数调整」的**二级面板**(一个带输入框的浮动面板,锚定在该按钮下方)。 + * @brief 打开「系数」的**二级面板**(一个整行输入框 + 标题栏右侧的还原按钮)。 * * 与自定义颜色入口同一套交互:点击按钮 → 抬起时展开浮动面板 → 面板内的输入框可编辑 - * 绘制前乘上的系数(默认 1),回车 / 点击其它位置提交,面板保持打开(与网格 Y 轴上下限 - * 的菜单内联输入框一致)。 + * 绘制前乘上的系数(默认 1,至少显示两位小数),回车 / 点击其它位置提交,面板保持打开 + *(与网格 Y 轴上下限的菜单内联输入框一致)。“还原”图标按钮把系数重置为 1。 * * @return 面板已打开返回 true。 */ bool OpenGainMenu(); + /** + * @brief 打开「镜像」的**二级面板**(两行带复选框的选项:水平镜像 / 垂直镜像)。 + * + * 两项都是“切换项”,点击后勾选状态立即生效且**菜单保持打开**,方便连续勾选; + * 只要任一项被勾选,「镜像」按钮即显示为激活态。 + * + * @return 面板已打开返回 true。 + */ + bool OpenMirrorMenu(); + + /** + * @brief 清除样式区的悬停高亮。 + * + * 二级面板(弹出菜单)一打开就会接管鼠标事件,样式区不再收到 `WM_MOUSEMOVE`, + * 因此必须在打开面板时主动清掉悬停态,否则该按钮会一直保持"鼠标按住"般的高亮。 + */ + void ClearStyleHover(); + /** * @brief 判断当前标签页中全部信号是否均为可见状态。 * @return 全部可见且至少有一条信号返回 true。 @@ -658,7 +679,8 @@ namespace jdi bool m_pendingLaneMenu = false; /**< 左键抬起时是否弹出网格归属菜单。 */ SignalRef m_pendingLaneRef; /**< 待弹出菜单的目标信号。 */ bool m_pendingColorPicker = false; /**< 左键抬起时是否弹出拾色器。 */ - bool m_pendingGainMenu = false; /**< 左键抬起时是否弹出「系数调整」的系数输入二级面板。 */ + bool m_pendingGainMenu = false; /**< 左键抬起时是否弹出「系数」的二级面板。 */ + bool m_pendingMirrorMenu = false; /**< 左键抬起时是否弹出「镜像」的二级面板。 */ /** * @brief 待确认的「关闭类」按钮(✕)。 diff --git a/Components/SignalPanel/Src/SignalPanel.cpp b/Components/SignalPanel/Src/SignalPanel.cpp index ddc26a0..f86ba84 100644 --- a/Components/SignalPanel/Src/SignalPanel.cpp +++ b/Components/SignalPanel/Src/SignalPanel.cpp @@ -108,9 +108,9 @@ namespace jdi const float kAdjustGap = 6.0f; /**< 「调整」行三个按钮之间的间距。 */ /** 「调整」行的按钮下标(与 `m_adjustRects` 同序)。 */ - const int kAdjustGain = 0; /**< 系数调整(点击展开系数输入二级面板)。 */ - const int kAdjustAbsolute = 1; /**< 取绝对值。 */ - const int kAdjustInvert = 2; /**< 中心反转。 */ + const int kAdjustGain = 0; /**< 系数(点击展开系数二级面板)。 */ + const int kAdjustMirror = 1; /**< 镜像(点击展开镜像二级面板)。 */ + const int kAdjustAbsolute = 2; /**< 绝对值。 */ const int kAdjustCount = 3; /**< 按钮数量。 */ const float kWidthMin = 0.5f; /**< 允许的最小线宽(px)。 */ @@ -167,30 +167,28 @@ namespace jdi } /** - * @brief 把系数增益格式化为界面文本(最多 4 位小数,并去掉无意义的尾随 0 与小数点)。 + * @brief 把系数格式化为界面文本:**至少两位小数**,精度更高时按实际精度显示。 * - * 默认值 1 因此显示为 `1`(而不是 `1.0000`),输入 2.5 显示为 `2.5`。 + * 例:`1` → `1.00`、`2.5` → `2.50`、`0.125` → `0.125`、`1.2345678` → `1.2345678`。 + * 实现:从 2 位小数开始递增位数,直到文本能精确表示该系数(相对误差 1e-7,与 float 精度匹配)。 * - * @param [IN] gain 增益系数。 + * @param [IN] gain 系数。 * @return 显示文本。 */ std::wstring FormatGainText(float gain) { - std::wstring text = jdi::FormatNumber(static_cast(gain), 4); - if (text.find(L'.') != std::wstring::npos) + const double value = static_cast(gain); + const double tolerance = std::max(1.0e-9, std::fabs(value) * 1.0e-7); + for (int decimals = 2; decimals <= 9; ++decimals) { - std::size_t end = text.size(); - while (end > 0 && text[end - 1] == L'0') + const std::wstring text = jdi::FormatNumber(value, decimals); + const double parsed = std::wcstod(text.c_str(), nullptr); + if (std::fabs(parsed - value) <= tolerance) { - --end; + return text; } - if (end > 0 && text[end - 1] == L'.') - { - --end; - } - text.erase(end); } - return text; + return jdi::FormatNumber(value, 9); } } @@ -226,6 +224,7 @@ namespace jdi m_pendingLaneMenu = false; m_pendingColorPicker = false; m_pendingGainMenu = false; + m_pendingMirrorMenu = false; m_pendingClosePart = RowPart::NONE; m_pendingCloseRow = -1; m_pendingFilterClear = false; @@ -307,8 +306,8 @@ namespace jdi const float fx = static_cast(x); const float fy = static_cast(y); if (UiPointInRect(m_adjustRects[kAdjustGain], fx, fy) || - UiPointInRect(m_adjustRects[kAdjustAbsolute], fx, fy) || - UiPointInRect(m_adjustRects[kAdjustInvert], fx, fy) || UiPointInRect(m_widthValueRect, fx, fy) || + UiPointInRect(m_adjustRects[kAdjustMirror], fx, fy) || + UiPointInRect(m_adjustRects[kAdjustAbsolute], fx, fy) || UiPointInRect(m_widthValueRect, fx, fy) || UiPointInRect(m_opacityValueRect, fx, fy)) { return LoadCursorW(nullptr, IDC_HAND); @@ -1276,8 +1275,8 @@ namespace jdi } } - // ---- 第三行:调整(系数调整 / 取绝对值 / 中心反转,三个切换类按钮)---- - // 三者依次作用于绘制值:系数(点击展开二级面板输入)→ 取绝对值 → 中心反转。 + // ---- 第三行:调整(系数 / 镜像 / 绝对值,三个切换类按钮)---- + // 结算顺序固定为「系数 → 绝对值 → 镜像」;「系数」「镜像」点击后展开二级面板。 { paintRowLabel(2, L"调整"); @@ -1292,10 +1291,11 @@ namespace jdi UiText(g, label, m_fonts->uiSmall, rect, checked ? m_theme->textStrong : m_theme->text, TextAlign::CENTER); }; - // 系数调整:仅当系数**不等于默认值 1** 时才显示为"已启用"(与其它切换按钮的启用样式一致)。 - paintToggle(kAdjustGain, L"系数调整", !GainIsDefault(style->gain), StyleHit::GAIN_ADJUST); - paintToggle(kAdjustAbsolute, L"取绝对值", style->absolute, StyleHit::ABSOLUTE_TOGGLE); - paintToggle(kAdjustInvert, L"中心反转", style->inverted, StyleHit::INVERT_TOGGLE); + // 系数:仅当系数**不等于默认值 1** 时才显示为"已启用"。 + paintToggle(kAdjustGain, L"系数", !GainIsDefault(style->gain), StyleHit::GAIN_ADJUST); + // 镜像:水平或垂直任一被勾选即显示为激活态。 + paintToggle(kAdjustMirror, L"镜像", style->Mirrored(), StyleHit::MIRROR_ADJUST); + paintToggle(kAdjustAbsolute, L"绝对值", style->absolute, StyleHit::ABSOLUTE_TOGGLE); } // ---- 第四行:线宽(数值可点击输入)---- @@ -1977,18 +1977,19 @@ namespace jdi return false; } - // 二级面板:一个标题行 + 一个内联输入框(与网格 Y 轴上下限的菜单内联编辑同一套做法, - // 提交后面板保持打开,因此可以连续微调;点击面板外或回车均会提交)。 + // 二级面板:标题行(右侧带「还原」图标按钮)+ 一整行系数输入框 + //(与网格 Y 轴上下限的菜单内联编辑同一套做法:提交后面板保持打开,可连续微调)。 std::vector items; MenuItem header; - header.kind = MenuItemKind::HEADER; + header.kind = MenuItemKind::HEADER_ACTION; header.label = L"系数调整"; + header.id = CMD_GAIN_RESET; // 点击标题栏右侧的还原图标 → 系数重置为 1 items.push_back(header); MenuItem edit; edit.kind = MenuItemKind::EDIT; - edit.label = L"绘制系数"; + edit.label.clear(); // 不再显示「绘制系数」说明文字:输入框独占整行 edit.id = CMD_GAIN_EDIT; edit.value = FormatGainText(style->gain); edit.filter = EditFilter::NUMBER; @@ -1999,9 +2000,61 @@ namespace jdi const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area; m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, static_cast(button.X), static_cast(button.Y + button.Height), items); + // ⚠️ 菜单一旦打开就会接管鼠标事件,样式区不会再收到 WM_MOUSEMOVE → + // 必须在这里主动清掉按钮的悬停态,否则按钮会"像鼠标一直按着"一样持续高亮。 + ClearStyleHover(); return true; } + bool SignalPanel::OpenMirrorMenu() + { + if (m_menu == nullptr || m_tab == nullptr) + { + return false; + } + const SignalStyle* style = StyleOf(SelectedRef()); + if (style == nullptr) + { + return false; + } + + // 两行带复选框的选项:点击即切换、且**保持菜单打开**(可连续勾选两项)。 + std::vector items; + + MenuItem horizontal; + horizontal.kind = MenuItemKind::NORMAL; + horizontal.label = L"水平镜像"; + horizontal.id = CMD_MIRROR_HORIZONTAL; + horizontal.checked = style->mirrorHorizontal; + horizontal.keepOpen = true; + items.push_back(horizontal); + + MenuItem vertical; + vertical.kind = MenuItemKind::NORMAL; + vertical.label = L"垂直镜像"; + vertical.id = CMD_MIRROR_VERTICAL; + vertical.checked = style->mirrorVertical; + vertical.keepOpen = true; + items.push_back(vertical); + + const gd::RectF& button = m_adjustRects[kAdjustMirror]; + const gd::RectF bounds = (m_clientBounds.Width > 1.0f) ? m_clientBounds : m_area; + m_menu->Open(MenuScope::SIGNAL_PANEL, bounds, static_cast(button.X), + static_cast(button.Y + button.Height), items); + ClearStyleHover(); + return true; + } + + void SignalPanel::ClearStyleHover() + { + if (m_hoverStyle == StyleHit::NONE && m_hoverStyleIndex < 0) + { + return; + } + m_hoverStyle = StyleHit::NONE; + m_hoverStyleIndex = -1; + } + bool SignalPanel::CloseTransientUi() { bool changed = false; @@ -2024,6 +2077,7 @@ namespace jdi m_pendingCloseRow = -1; m_pendingFilterClear = false; m_pendingGainMenu = false; + m_pendingMirrorMenu = false; return changed; } @@ -2119,14 +2173,14 @@ namespace jdi { hoverStyle = StyleHit::GAIN_ADJUST; } + if (hoverStyle == StyleHit::NONE && UiPointInRect(m_adjustRects[kAdjustMirror], fx, fy)) + { + hoverStyle = StyleHit::MIRROR_ADJUST; + } if (hoverStyle == StyleHit::NONE && UiPointInRect(m_adjustRects[kAdjustAbsolute], fx, fy)) { hoverStyle = StyleHit::ABSOLUTE_TOGGLE; } - if (hoverStyle == StyleHit::NONE && UiPointInRect(m_adjustRects[kAdjustInvert], fx, fy)) - { - hoverStyle = StyleHit::INVERT_TOGGLE; - } } if (hoverRow == m_hoverRow && hoverStyle == m_hoverStyle && hoverStyleIndex == m_hoverStyleIndex && @@ -2203,6 +2257,7 @@ namespace jdi m_pendingLaneMenu = false; m_pendingColorPicker = false; m_pendingGainMenu = false; + m_pendingMirrorMenu = false; // 点击当前输入框以外的位置时提交输入。 if (m_editField != StyleEditField::NONE && @@ -2268,23 +2323,23 @@ namespace jdi { return BeginEdit(StyleEditField::OPACITY); } - // 「调整」行:系数调整(按下只登记,**抬起**时才展开二级面板, - // 与自定义颜色入口同一套交互)、取绝对值 / 中心反转(单击即切换)。 + // 「调整」行:系数与镜像(按下只登记,**抬起**时才展开各自的二级面板, + // 与自定义颜色入口同一套交互)、绝对值(单击即切换)。 if (UiPointInRect(m_adjustRects[kAdjustGain], fx, fy)) { m_pendingGainMenu = true; return true; } + if (UiPointInRect(m_adjustRects[kAdjustMirror], fx, fy)) + { + m_pendingMirrorMenu = true; + return true; + } if (UiPointInRect(m_adjustRects[kAdjustAbsolute], fx, fy)) { style->absolute = !style->absolute; return true; } - if (UiPointInRect(m_adjustRects[kAdjustInvert], fx, fy)) - { - style->inverted = !style->inverted; - return true; - } } // 列表行。 @@ -2424,7 +2479,7 @@ namespace jdi } } - // 「系数调整」:按下时只登记,抬起时指针仍在同一按钮上才展开二级面板 + // 「系数」/「镜像」:按下时只登记,抬起时指针仍在同一按钮上才展开二级面板 //(与自定义颜色入口一致:按住后移开再松开视为放弃)。 if (m_pendingGainMenu) { @@ -2436,6 +2491,16 @@ namespace jdi changed = OpenGainMenu() || changed; } } + if (m_pendingMirrorMenu) + { + m_pendingMirrorMenu = false; + const float fx = static_cast(x); + const float fy = static_cast(y); + if (UiPointInRect(m_adjustRects[kAdjustMirror], fx, fy) && m_tab != nullptr) + { + changed = OpenMirrorMenu() || changed; + } + } if (m_pendingLaneMenu) { @@ -2755,7 +2820,7 @@ namespace jdi return false; } - // 「系数调整」二级面板里的输入框提交:就地更新绘制系数,面板保持打开 + // 「系数」二级面板里的输入框提交:就地更新绘制系数,面板保持打开 //(与网格 Y 轴上下限的菜单内联编辑一致),并把夹取后的真实值回写到输入框上。 if (result.editCommit && result.id == CMD_GAIN_EDIT) { @@ -2763,9 +2828,9 @@ namespace jdi if (style != nullptr) { wchar_t* stop = nullptr; - const double parsed = std::wcstod(result.editText.c_str(), &stop); - // 非法/空输入保持原值(只把真实值回写到输入框,避免用户看到错误的数字)。 const std::wstring trimmed = Trim(result.editText); + const double parsed = std::wcstod(trimmed.c_str(), &stop); + // 非法/空输入保持原值(只把真实值回写到输入框,避免用户看到错误的数字)。 if (!trimmed.empty() && stop != trimmed.c_str() && std::isfinite(parsed)) { style->gain = static_cast(std::max(-kGainLimit, std::min(parsed, kGainLimit))); @@ -2778,6 +2843,36 @@ namespace jdi return false; } + // 标题栏右侧的「还原」图标:把系数重置为默认值 1(面板保持打开,输入框立即回显)。 + if (result.id == CMD_GAIN_RESET) + { + if (SignalStyle* style = StyleOf(SelectedRef())) + { + style->gain = 1.0f; + if (m_menu != nullptr) + { + m_menu->UpdateItemValue(CMD_GAIN_EDIT, FormatGainText(style->gain)); + } + } + return false; + } + + // 「镜像」二级面板:两行带复选框的切换项(面板保持打开,勾选状态就地回写)。 + if (result.id == CMD_MIRROR_HORIZONTAL || result.id == CMD_MIRROR_VERTICAL) + { + if (SignalStyle* style = StyleOf(SelectedRef())) + { + const bool horizontal = (result.id == CMD_MIRROR_HORIZONTAL); + bool& flag = horizontal ? style->mirrorHorizontal : style->mirrorVertical; + flag = !flag; + if (m_menu != nullptr) + { + m_menu->UpdateItemLabel(result.id, horizontal ? L"水平镜像" : L"垂直镜像", flag, true); + } + } + return false; + } + bool needRelayout = false; switch (result.id) diff --git a/Utilities/Inc/DataModel.hpp b/Utilities/Inc/DataModel.hpp index 899683e..ce59bbf 100644 --- a/Utilities/Inc/DataModel.hpp +++ b/Utilities/Inc/DataModel.hpp @@ -1,6 +1,8 @@ #ifndef __DATA_MODEL_HPP__ #define __DATA_MODEL_HPP__ +#include +#include #include #include #include @@ -105,24 +107,35 @@ namespace jdi DrawMode mode = DrawMode::LINE; /**< 绘制方式。 */ /** - * 绘制前的数值变换(三者**依次**作用,见 `ApplyDrawTransform`): - * 1. `gain` 系数增益:采样值乘以该系数(默认 1,即不改变); - * 2. `absolute` 取绝对值:|value|; - * 3. `inverted` 中心反转:按 Y = 0 上下反向(value → −value)。 + * 绘制前的数值变换(依次作用,见 `ApplyDrawTransform`): + * 1. `gain` 系数:采样值乘以该系数(默认 1,即不改变); + * 2. `absolute` 绝对值:|value|; + * 3. 镜像:`mirrorVertical` 按 Y = 0 上下镜像(value → −value), + * `mirrorHorizontal` 为**数据反序**(把采样序列头尾对调后绘制,即水平翻转)。 + * + * ⚠️ 结算顺序固定为「系数 → 绝对值 → 镜像」(用户明确要求), + * 其中镜像的两个方向互相正交:垂直改**值**、水平改**取值下标**。 */ - float gain = 1.0f; /**< 系数增益,默认 1.0。 */ - bool absolute = false; /**< 是否对绘制值取绝对值,默认关闭。 */ - bool inverted = false; /**< 是否按 Y=0 中心反转绘制值,默认关闭。 */ + float gain = 1.0f; /**< 系数,默认 1.0(不改变)。 */ + bool absolute = false; /**< 是否对绘制值取绝对值,默认关闭。 */ + bool mirrorVertical = false; /**< 是否按 Y=0 上下镜像(值取反),默认关闭。 */ + bool mirrorHorizontal = false; /**< 是否水平镜像(数据反序绘制),默认关闭。 */ + + /** @brief 镜像功能的任一分量是否启用(决定「镜像」按钮的激活态)。 */ + bool Mirrored() const { return mirrorVertical || mirrorHorizontal; } }; /** * @brief 把原始采样值变换为**绘制用值**。 * - * 顺序固定为「系数增益 → 取绝对值 → 中心反转」(用户明确要求中心反转最后结算), - * 例如增益 2、原始值 1.5 → 2×1.5 = 3;再取绝对值仍为 3;再中心反转为 −3。 + * 顺序固定为「系数 → 绝对值 → 镜像(垂直分量)」,例如系数 2、原始值 1.5 → + * 2×1.5 = 3;再取绝对值仍为 3;若开启垂直镜像则为 −3。 * NaN 经乘/取绝对值/取反后仍为 NaN(保持断线语义)。 * - * @param [IN] style 信号外观(提供增益与两个开关)。 + * ⚠️ 水平镜像**不在这里处理**:它是"取值下标反序"(把采样序列头尾对调), + * 必须在遍历采样时按 `SourceIndexFor(style, count, index)` 取源下标。 + * + * @param [IN] style 信号外观(提供系数与两个数值开关)。 * @param [IN] value 原始采样值。 * @return 绘制用值。 */ @@ -136,7 +149,7 @@ namespace jdi { value = (value < 0.0) ? -value : value; } - if (style.inverted) + if (style.mirrorVertical) { value = -value; } @@ -144,17 +157,81 @@ namespace jdi } /** - * @brief 判断数值变换是否**保持大小顺序**(正增益、且不取绝对值、不反转)。 + * @brief 把遍历下标映射为**源采样下标**(处理水平镜像的"数据反序")。 * - * 只有保序时,粗粒度包络层(每组的 min/max)才能直接按增益缩放后继续使用; - * 取绝对值 / 反转会把最小值与最大值互换,此时必须回退到逐采样点变换。 + * 水平镜像 = 头尾对调:遍历下标 `index` 取源下标 `count - 1 - index`。 + * 关闭时原样返回,因此该函数在有/无镜像时都是 O(1)、无分支代价可忽略。 + * + * @param [IN] style 信号外观。 + * @param [IN] count 采样点数。 + * @param [IN] index 遍历下标。 + * @return 源采样下标。 + */ + inline std::size_t SourceIndexFor(const SignalStyle& style, std::size_t count, std::size_t index) + { + if (!style.mirrorHorizontal || count == 0) + { + return index; + } + return (count - 1) - index; + } + + /** + * @brief 判断数值变换是否**保持大小顺序**(正系数、不取绝对值、不垂直镜像)。 + * + * 只有保序时,粗粒度包络层(每组的 min/max)才能直接按系数缩放后继续使用; + * 取绝对值 / 垂直镜像会把最小值与最大值互换,此时必须回退到逐采样点变换。 * * @param [IN] style 信号外观。 * @return 变换单调递增返回 true。 */ inline bool DrawTransformOrderPreserving(const SignalStyle& style) { - return (style.gain > 0.0f) && !style.absolute && !style.inverted; + return (style.gain > 0.0f) && !style.absolute && !style.mirrorVertical; + } + + /** + * @brief 把**数值区间**按绘制变换映射为变换后的区间(用于整段 Y 轴自适应)。 + * + * 三个变换作用在区间端点上是精确的(系数为线性、绝对值为 |x|、镜像为线性), + * 因此 `[minValue, maxValue]` 映射后就是真实的数据范围,无需扫描采样点。 + * ⚠️ 水平镜像不改变值的范围,因此不影响本函数。 + * + * @param [IN] style 信号外观。 + * @param [IN,OUT] low 区间下限,返回变换后的下限。 + * @param [IN,OUT] high 区间上限,返回变换后的上限。 + */ + inline void TransformValueRange(const SignalStyle& style, double& low, double& high) + { + if (low > high) + { + std::swap(low, high); + } + const double gain = static_cast(style.gain); + if (gain != 1.0) + { + low *= gain; + high *= gain; + if (low > high) + { + std::swap(low, high); + } + } + if (style.absolute) + { + // |x| 在 [low, high] 上的取值范围:跨 0 时从 0 起,否则从较近的一端起。 + const double first = std::fabs(low); + const double second = std::fabs(high); + const bool spansZero = (low <= 0.0) && (high >= 0.0); + low = spansZero ? 0.0 : std::min(first, second); + high = std::max(first, second); + } + if (style.mirrorVertical) + { + const double flipped = -low; + low = -high; + high = flipped; + } } /** @@ -618,6 +695,25 @@ namespace jdi bool SampleSignalAt(const DataFile& file, const SignalData& signal, double time, bool interpolate, double& value); + /** + * @brief 取某一时刻**画面上显示**的取值(含水平镜像的"数据反序")。 + * + * 与 `SampleSignalAt` 的唯一区别:水平镜像启用时用 `SourceIndexFor` 把下标映射到源采样 + *(头尾对调),因此返回的正是当前绘制出来的那条曲线在该时刻的取值 —— 光标读数、 + * 数据点标注都必须用它,否则读数会与看到的波形不一致。 + * ⚠️ 系数 / 绝对值 / 垂直镜像不在这里处理,由 `ApplyDrawTransform` 负责。 + * + * @param [IN] file 信号所属的已加载数据分组(提供时间轴)。 + * @param [IN] signal 目标信号。 + * @param [IN] style 显示外观(只用 `mirrorHorizontal`)。 + * @param [IN] time 采样时刻。 + * @param [IN] interpolate 是否在相邻采样点之间做线性插值。 + * @param [OUT] value 输出采样值,缺失时为 NaN。 + * @return 时间点落在数据范围内且取到有效值返回 true。 + */ + bool SampleDisplayedSignalAt(const DataFile& file, const SignalData& signal, + const SignalStyle& style, double time, bool interpolate, double& value); + /** * @brief 计算 N 点滑动平均(窗口 = [i-N, i+N],边界按实际窗口长度)。 * diff --git a/Utilities/Inc/PopupMenu.hpp b/Utilities/Inc/PopupMenu.hpp index edcf1d4..aa3f0d7 100644 --- a/Utilities/Inc/PopupMenu.hpp +++ b/Utilities/Inc/PopupMenu.hpp @@ -37,7 +37,8 @@ namespace jdi HEADER = 2, /**< 分组标题(不可点击)。 */ SWATCHES = 3, /**< 颜色色块行。 */ EDIT = 4, /**< 带内联输入框的项:在菜单内部直接编辑数值(菜单不关闭)。 */ - SUBMENU = 5 /**< 带二级子菜单的项:鼠标悬停时向右展开。 */ + SUBMENU = 5, /**< 带二级子菜单的项:鼠标悬停时向右展开。 */ + HEADER_ACTION = 6 /**< 分组标题 + **右侧图标按钮**(如「还原」),图标可点击。 */ }; /** @@ -56,6 +57,13 @@ namespace jdi std::wstring value; /**< EDIT 项的当前数值文本(非编辑态显示)。 */ EditFilter filter = EditFilter::ANY; /**< EDIT 项的输入过滤规则。 */ std::vector submenu; /**< 二级子菜单项(kind == SUBMENU 时有效)。 */ + /** + * 普通项被点击后是否**保持菜单打开**。 + * + * 用于「带复选框的切换项」(如镜像的水平/垂直):可以连续勾选多项, + * 勾选状态通过 `UpdateItemLabel` 回写。默认 false(点击即关):与既有行为一致。 + */ + bool keepOpen = false; }; /** @@ -283,6 +291,17 @@ namespace jdi */ gd::RectF EditFieldRect(int index) const; + /** + * @brief 计算 `HEADER_ACTION` 项右侧**图标按钮**(如「还原」)的矩形。 + * + * 图标紧贴该行右端(右边距与其它行一致、行内垂直居中),大小按缩放比例推导。 + * 鼠标悬停时该图标会高亮,点击即下发该项的命令。 + * + * @param [IN] index 项目下标。 + * @return 图标矩形;非 `HEADER_ACTION` 项或非法下标返回空矩形。 + */ + gd::RectF HeaderIconRect(int index) const; + /** * @brief 计算 EDIT 项输入框的“自然宽度”(排版与绘制共用同一公式)。 * @param [IN] item 菜单项。 @@ -353,6 +372,7 @@ namespace jdi std::vector> m_swatchRects; /**< 各项内的色块矩形。 */ int m_hoverIndex = -1; /**< 当前悬停项。 */ + int m_hoverIconIndex = -1; /**< 当前悬停的 `HEADER_ACTION` 图标所在项下标。 */ int m_hoverSwatch = -1; /**< 当前悬停色块。 */ int m_pressedIndex = -1; /**< 按下时的项目下标。 */ int m_pressedSwatch = -1; /**< 按下时的色块下标。 */ diff --git a/Utilities/Src/DataModel.cpp b/Utilities/Src/DataModel.cpp index 56947c9..07e9dac 100644 --- a/Utilities/Src/DataModel.cpp +++ b/Utilities/Src/DataModel.cpp @@ -1583,6 +1583,81 @@ namespace jdi return true; } + bool SampleDisplayedSignalAt(const DataFile& file, const SignalData& signal, + const SignalStyle& style, double time, bool interpolate, double& value) + { + // 未启用水平镜像时与 SampleSignalAt 完全等价(避免两套实现产生细微差异)。 + if (!style.mirrorHorizontal) + { + return SampleSignalAt(file, signal, time, interpolate, value); + } + + value = kMissing; + if (file.time.empty() || signal.values.empty()) + { + return false; + } + if (time < file.minTime || time > file.maxTime) + { + return false; + } + + const std::vector& values = signal.values; + const std::size_t count = std::min(file.time.size(), values.size()); + if (count == 0) + { + return false; + } + // 画面上第 i 个位置用源下标 srcIndex(i) 的值(水平镜像 = 数据反序)。 + const auto sample = [&](std::size_t index) -> double + { + return values[SourceIndexFor(style, count, index)]; + }; + + if (!file.timeMonotonic) + { + const int nearest = FindNearestSample(file, time); + if (nearest < 0 || static_cast(nearest) >= count) + { + return false; + } + value = sample(static_cast(nearest)); + return std::isfinite(value); + } + + const std::vector::const_iterator lower = + std::lower_bound(file.time.begin(), file.time.end(), time); + const std::size_t rightIndex = static_cast(lower - file.time.begin()); + if (rightIndex == 0) + { + value = sample(0); + return std::isfinite(value); + } + if (rightIndex >= count) + { + value = sample(count - 1); + return std::isfinite(value); + } + + const std::size_t leftIndex = rightIndex - 1; + const double t0 = file.time[leftIndex]; + const double t1 = file.time[rightIndex]; + const double v0 = sample(leftIndex); + const double v1 = sample(rightIndex); + + if (!interpolate || !std::isfinite(v0) || !std::isfinite(v1) || !(t1 > t0)) + { + const double leftDistance = std::fabs(time - t0); + const double rightDistance = std::fabs(t1 - time); + value = (leftDistance <= rightDistance) ? v0 : v1; + return std::isfinite(value); + } + + const double ratio = (time - t0) / (t1 - t0); + value = v0 + (v1 - v0) * ratio; + return true; + } + // ============================ TabConfig ============================ TabConfig::TabConfig() @@ -2234,8 +2309,17 @@ namespace jdi { continue; } - low = std::min(low, signal->minValue); - high = std::max(high, signal->maxValue); + // ⚠️ 自适应必须基于**画出来的曲线**:先按绘制变换(系数 → 绝对值 → 镜像)把该信号的 + // 原始值域映射成显示值域,否则调整系数/镜像后自适应范围仍按原始数据算(范围错误)。 + const SignalStyle* style = dataSet.Style(ref); + double signalLow = signal->minValue; + double signalHigh = signal->maxValue; + if (style != nullptr) + { + TransformValueRange(*style, signalLow, signalHigh); + } + low = std::min(low, signalLow); + high = std::max(high, signalHigh); } if (!std::isfinite(low) || !std::isfinite(high)) diff --git a/Utilities/Src/PopupMenu.cpp b/Utilities/Src/PopupMenu.cpp index 499e066..34fe599 100644 --- a/Utilities/Src/PopupMenu.cpp +++ b/Utilities/Src/PopupMenu.cpp @@ -27,6 +27,54 @@ namespace jdi const float kEditFieldPadV = 5.0f; /**< 输入框上下内缩。 */ const float kSubArrowSize = 4.5f; /**< 子菜单指示箭头尺寸。 */ const float kSubOverlap = 2.0f; /**< 子菜单与主菜单重叠的宽度(避免中间露出缝隙)。 */ + const float kHeaderIconSize = 16.0f; /**< `HEADER_ACTION` 右侧图标按钮的边长。 */ + const float kHeaderIconGap = 6.0f; /**< 标题文字与图标按钮之间的最小间隔。 */ + const float kRowFieldWidth = 132.0f; /**< 整行输入框(无说明文字)的默认宽度。 */ + + /** + * @brief 绘制「还原/重置」图标(逆时针回环箭头)。 + * + * 用一段圆弧 + 一个三角箭头拼成,与界面里其它自绘图标(加号、叉号)保持同一笔画风格。 + * 不依赖任何字体或资源,因此在任何 DPI 下都清晰。 + * + * @param [IN,OUT] g 绘图上下文。 + * @param [IN] rect 图标外接矩形(正方形)。 + * @param [IN] color 笔画颜色。 + * @param [IN] scale DPI 缩放比例。 + */ + void PaintResetIcon(gd::Graphics& g, const gd::RectF& rect, std::uint32_t color, float scale) + { + const float inset = 2.5f * scale; + gd::RectF box(rect.X + inset, rect.Y + inset, rect.Width - inset * 2.0f, + rect.Height - inset * 2.0f); + if (box.Width <= 1.0f || box.Height <= 1.0f) + { + return; + } + gd::Pen pen(gd::Color((color >> 24) & 0xFFu, (color >> 16) & 0xFFu, (color >> 8) & 0xFFu, + color & 0xFFu), + std::max(1.0f, 1.5f * scale)); + pen.SetStartCap(gd::LineCapRound); + pen.SetEndCap(gd::LineCapRound); + // 缺口留在右上角(从 300° 到 20° 处断开),与常见的"重置/刷新"图标一致。 + g.DrawArc(&pen, box.X, box.Y, box.Width, box.Height, 300.0f, 300.0f); + + // 箭头:位于缺口末端(约 300° 方向),指向前进方向。 + const float cx = box.X + box.Width * 0.5f; + const float cy = box.Y + box.Height * 0.5f; + const float radius = box.Width * 0.5f; + const float angle = 300.0f * 3.14159265f / 180.0f; + const float tipX = cx + std::cos(angle) * radius; + const float tipY = cy + std::sin(angle) * radius; + const float size = std::max(2.5f, 3.2f * scale); + gd::PointF arrow[3]; + arrow[0] = gd::PointF(tipX, tipY); + arrow[1] = gd::PointF(tipX - size, tipY - size * 0.25f); + arrow[2] = gd::PointF(tipX - size * 0.25f, tipY - size); + gd::SolidBrush brush(gd::Color((color >> 24) & 0xFFu, (color >> 16) & 0xFFu, + (color >> 8) & 0xFFu, color & 0xFFu)); + g.FillPolygon(&brush, arrow, 3); + } } PopupMenu::PopupMenu() = default; @@ -49,6 +97,7 @@ namespace jdi case MenuItemKind::SEPARATOR: return kSeparatorHeight * m_scale; case MenuItemKind::HEADER: + case MenuItemKind::HEADER_ACTION: return kHeaderHeight * m_scale; case MenuItemKind::SWATCHES: return kSwatchRowHeight * m_scale; @@ -88,11 +137,14 @@ namespace jdi static_cast(std::max(0, count - 1)) * kSwatchGap * m_scale; contentWidth = std::max(contentWidth, width); } - else if (item.kind == MenuItemKind::HEADER) + else if (item.kind == MenuItemKind::HEADER || item.kind == MenuItemKind::HEADER_ACTION) { // 标题与普通项共用同一文字列与右侧标记列,保证整个菜单的左右边距一致。 + const float iconExtra = (item.kind == MenuItemKind::HEADER_ACTION) + ? (kHeaderIconSize + kHeaderIconGap) * m_scale + : 0.0f; const float width = (kPadH + kItemPad) * 2.0f * m_scale + kMarkerColumn * m_scale + - kMarkerGap * m_scale + + kMarkerGap * m_scale + iconExtra + UiMeasureTextWidth(m_fonts->uiSmallBold, item.label); contentWidth = std::max(contentWidth, width); } @@ -163,8 +215,10 @@ namespace jdi float PopupMenu::NaturalEditFieldWidth(const MenuItem& item) const { - return std::max(kEditFieldWidth * m_scale, - UiMeasureTextWidth(m_fonts->uiSmall, item.value) + 20.0f * m_scale); + const float natural = std::max(kEditFieldWidth * m_scale, + UiMeasureTextWidth(m_fonts->uiSmall, item.value) + 20.0f * m_scale); + // 无说明文字 = 整行输入框(如「系数调整」面板):给一个更宽的默认值,视觉上独占整行。 + return item.label.empty() ? std::max(natural, kRowFieldWidth * m_scale) : natural; } void PopupMenu::CloseSubmenu() @@ -306,23 +360,49 @@ namespace jdi // 输入框靠右对齐到条目内容区右边界;宽度取自然宽度,但必须给说明文字留出自然宽度, // 避免菜单被最大宽度夹取后输入框把左侧标签挤成省略号。 + // ⚠️ 说明文字为空(例如「系数调整」面板的整行输入框)时不设 3/4 上限, + // 让输入框独占整行。 const gd::RectF& rect = m_itemRects[static_cast(index)]; const float contentWidth = std::max(40.0f * m_scale, rect.Width - 2.0f * kItemPad * m_scale); const float labelWidth = UiMeasureTextWidth(m_fonts->uiSmall, item.label) + kEditFieldGap * m_scale; float width = NaturalEditFieldWidth(item); width = std::min(width, std::max(40.0f * m_scale, contentWidth - labelWidth)); - width = std::min(width, contentWidth * 0.75f); + if (item.label.empty()) + { + // 整行输入框(无说明文字):占满内容区,视觉上就是一个独立的一行输入框。 + width = contentWidth; + } + else + { + width = std::min(width, contentWidth * 0.75f); + } const float height = rect.Height - kEditFieldPadV * 2.0f * m_scale; return gd::RectF(rect.X + rect.Width - kItemPad * m_scale - width, rect.Y + (rect.Height - height) * 0.5f, width, height); } + gd::RectF PopupMenu::HeaderIconRect(int index) const + { + if (index < 0 || index >= static_cast(m_items.size())) + { + return gd::RectF(); + } + const MenuItem& item = m_items[static_cast(index)]; + if (item.kind != MenuItemKind::HEADER_ACTION) + { + return gd::RectF(); + } + const gd::RectF& rect = m_itemRects[static_cast(index)]; + const float size = std::min(kHeaderIconSize * m_scale, std::max(6.0f, rect.Height - 4.0f * m_scale)); + return gd::RectF(rect.X + rect.Width - kItemPad * m_scale - size, + rect.Y + (rect.Height - size) * 0.5f, size, size); + } + bool PopupMenu::IsOnEditField(int x, int y) const { if (m_editIndex < 0) - { - return false; + { return false; } const gd::RectF rect = EditFieldRect(m_editIndex); return UiPointInRect(rect, static_cast(x), static_cast(y)); @@ -509,6 +589,7 @@ namespace jdi m_scope = scope; m_open = true; m_hoverIndex = -1; + m_hoverIconIndex = -1; m_hoverSwatch = -1; m_pressedIndex = -1; m_pressedSwatch = -1; @@ -529,6 +610,7 @@ namespace jdi m_itemRects.clear(); m_swatchRects.clear(); m_hoverIndex = -1; + m_hoverIconIndex = -1; m_hoverSwatch = -1; m_pressedIndex = -1; m_pressedSwatch = -1; @@ -581,8 +663,7 @@ namespace jdi } if (!UiPointInRect(m_itemRects[index], fx, fy)) { - continue; - } + continue; } if (item.kind == MenuItemKind::SWATCHES) { @@ -723,6 +804,29 @@ namespace jdi changed = true; } + // `HEADER_ACTION` 的图标按钮单独跟踪悬停状态(用于高亮,其余标题区域不响应)。 + { + int iconHover = -1; + for (std::size_t itemIndex = 0; itemIndex < m_items.size(); ++itemIndex) + { + if (m_items[itemIndex].kind != MenuItemKind::HEADER_ACTION) + { + continue; + } + const gd::RectF icon = HeaderIconRect(static_cast(itemIndex)); + if (icon.Width > 0.0f && UiPointInRect(icon, fx, fy)) + { + iconHover = static_cast(itemIndex); + break; + } + } + if (iconHover != m_hoverIconIndex) + { + m_hoverIconIndex = iconHover; + changed = true; + } + } + // 悬停到带子菜单的项 → 向右展开;移开到其它条目或离开菜单 → 收起。 if (onSubmenuParent) { @@ -857,6 +961,22 @@ namespace jdi return true; } + // `HEADER_ACTION`:只有**点在右侧图标按钮上**才下发命令(标题区域不响应); + // 菜单保持打开(这是一个可反复使用的小按钮,如「还原系数」)。 + if (item.kind == MenuItemKind::HEADER_ACTION) + { + const gd::RectF icon = HeaderIconRect(index); + if (icon.Width > 0.0f && UiPointInRect(icon, static_cast(x), static_cast(y))) + { + result.valid = true; + result.scope = m_scope; + result.id = item.id; + result.swatchIndex = -1; + result.itemRect = icon; + } + return true; + } + // 编辑项:单击进入菜单内联编辑,菜单保持打开。 if (item.kind == MenuItemKind::EDIT) { @@ -880,6 +1000,12 @@ namespace jdi result.swatchIndex = swatchIndex; } + // 带复选框的切换项(如镜像的水平/垂直)选择后**保持菜单打开**,便于连续勾选。 + if (item.keepOpen) + { + return true; + } + Close(); return true; } @@ -1043,6 +1169,29 @@ namespace jdi continue; } + if (item.kind == MenuItemKind::HEADER_ACTION) + { + // 标题 + 右侧「还原」图标按钮:文字与普通标题同一列;图标可点击(悬停高亮)。 + const gd::RectF icon = HeaderIconRect(static_cast(index)); + const float labelWidth = + std::max(8.0f, icon.X - rect.X - kItemPad * m_scale - kHeaderIconGap * m_scale); + UiText(g, UiEllipsize(item.label, m_fonts->uiSmallBold, labelWidth), m_fonts->uiSmallBold, + gd::RectF(rect.X + kItemPad * m_scale, rect.Y, labelWidth, rect.Height), + m_theme->textMuted, TextAlign::LEFT); + if (icon.Width > 0.0f) + { + const bool iconHovered = (static_cast(index) == m_hoverIconIndex); + if (iconHovered) + { + UiFillRoundRect(g, gd::RectF(icon.X - 2.0f * m_scale, icon.Y - 2.0f * m_scale, + icon.Width + 4.0f * m_scale, icon.Height + 4.0f * m_scale), + 4.0f * m_scale, WithAlpha(m_theme->accent, 0x33)); + } + PaintResetIcon(g, icon, iconHovered ? m_theme->accent : m_theme->text, m_scale); + } + continue; + } + const bool hovered = (static_cast(index) == m_hoverIndex) && (item.kind != MenuItemKind::SWATCHES || m_hoverSwatch < 0);