From fc77b0262fe43cf5e1d4e16d08c4e5c9506254c2 Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Tue, 13 Oct 2026 17:56:00 +0800 Subject: [PATCH] =?UTF-8?q?=E3=80=8C=E8=B0=83=E6=95=B4=E3=80=8D=E8=A1=8C?= =?UTF-8?q?=EF=BC=9A=E7=B3=BB=E6=95=B0/=E5=81=8F=E7=A7=BB=20=E6=8C=89?= =?UTF-8?q?=E9=92=AE=20+=20=E5=81=8F=E7=A7=BB=E8=B0=83=E6=95=B4=EF=BC=88Y?= =?UTF-8?q?=20=E8=BD=B4/=E6=97=B6=E9=97=B4=E8=BD=B4=EF=BC=89=E4=BA=8C?= =?UTF-8?q?=E7=BA=A7=E9=9D=A2=E6=9D=BF=EF=BC=9B=E7=BB=93=E7=AE=97=E9=A1=BA?= =?UTF-8?q?=E5=BA=8F=E5=8A=A0=E5=85=A5=E5=81=8F=E7=A7=BB=EF=BC=9B=E6=B4=BE?= =?UTF-8?q?=E7=94=9F=E4=BF=A1=E5=8F=B7=E7=BB=A7=E6=89=BF=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E5=8F=98=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按钮与面板: - 「系数」按钮改名为「系数/偏移」;点击展开的二级面板在原「系数调整」输入框下方新增「偏移调整」: 与「系数调整」同一套绘制(标题 + 标题栏右侧的还原图标),下面两行分别是「Y轴」「时间轴」, 每行左侧说明文字、右侧内联输入框(整行数值框)。 - 「Y轴」默认 0.00,非编辑态**始终带正负号**(+0.00 / -12.50),小数不足两位时补两位、更高精度保留更多位。 - 「时间轴」默认 0.00,非编辑态显示与双光标差值同一套复合单位文本(`Δt:500ms`、`Δt:1h 1min 2s`); 进入编辑态时显示以**秒**为单位的数值,并在输入框最右侧提示单位 `s`。 - 「偏移调整」的还原图标把两个偏移恢复为默认值 0;「系数调整」的还原图标仍只重置系数。 - 面板/输入框的编辑初值与显示文本分离(`MenuItem::editValue` / `editSuffix`), 非编辑态(`Δt:…`)与编辑态(`3662`)不一致时也能正确回显。 语义与结算顺序: - 结算顺序变为 **系数 → 绝对值 → 镜像 → 偏移**(前三步不变)。 - Y 偏移:给绘制值加常数(波形上下移动);时间偏移:采样绘制在 `t + offsetTime` 处 (例如 t=1s、偏移 -0.5s → 画在 0.5s)。 - ⚠️ Y 偏移以**未镜像空间**为准:垂直镜像开关变化时同步取反偏移量, 使绘制等价于「先加上偏移再镜像」(开关来回切换可逆)。 - 绘制链路全部跟随偏移:`BuildPolyline`(逐点描边 / 像素列包络 / 跨列边界插值 / 粗粒度分组层, 保序的 Y 偏移直接加在分组极值上)、派生预览曲线、光标取值读数(含 Δt)、 可见区间 Y 轴自适应、整段自适应的区间映射;折线缓存键、场景指纹、样式快照与图层指纹都纳入两个偏移。 派生信号(实验性功能「构建为新信号」): - 修复「预览是调整后的波形、构建出来却按原始状态绘制」:新信号**继承源信号的调整变换** (系数/绝对值/两个镜像/两个偏移)——网格预览绘制的是 `变换(滤波结果)`, 而构建出的信号只存了滤波结果,因此必须把变换一并继承; 时间轴偏移**只能**靠继承(派生信号与源信号共用同一文件时间轴,无法把时间平移烘进数据)。 离屏验证(不干扰用户): - ⚠️ harness 现在在每次注入鼠标消息后**立即释放本窗口的鼠标捕获** (宿主 WM_LBUTTONDOWN 会 SetCapture,隐藏窗口同样会把用户的真实鼠标输入吸走); 后续消息本来就是直接投递,无需捕获。 - 新增 [AP]:按钮标签不裁切、面板 5 行顺序与「偏移调整」位于系数输入框下方、 Y轴/时间轴行的标签与输入框同行且默认值/单位提示正确、时间轴编辑态显示秒值并在最右侧提示 `s`、 Y 偏移提交/显示正负号/波形整体平移量、时间偏移提交/Δt 复合单位(含 3662 → Δt:1h 1min 2s)/ 编辑态为秒、**按"偏移若干个采样点"构造的精确几何验证**(采样被画到移位后的像素位置、且不再画在原处)、 读数跟随、两个还原图标、垂直镜像对 Y 偏移取反(可逆)、 以及派生信号继承变换;[AL] 补充「系数→绝对值→镜像→偏移」的顺序与区间映射(含 Y 偏移)。 - 合计 645/645 通过;make clean && make all 零警告。 --- Components/Application/Src/Application.cpp | 37 +++- Components/PlotView/Inc/PlotView.hpp | 11 +- Components/PlotView/Src/PlotView.cpp | 239 ++++++--------------- Components/SignalPanel/Inc/SignalPanel.hpp | 5 +- Components/SignalPanel/Src/SignalPanel.cpp | 195 ++++++++++++++--- Utilities/Inc/DataModel.hpp | 64 ++++-- Utilities/Inc/PopupMenu.hpp | 32 ++- Utilities/Inc/Utf8Util.hpp | 24 +++ Utilities/Src/DataModel.cpp | 19 +- Utilities/Src/PopupMenu.cpp | 58 ++++- Utilities/Src/Utf8Util.cpp | 135 +++++++++++- 11 files changed, 567 insertions(+), 252 deletions(-) diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index 1d121f5..9a1de37 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -1525,6 +1525,29 @@ namespace jdi const SignalData& source = file->signals[static_cast(request.signalIndex)]; const std::wstring baseName = source.Title(); + // 0. 先把源信号的「调整」变换快照下来(下面 `InsertDerivedSignal` 会移动信号存储, + // 之后再取 `source` 会悬垂)。 + // + // ⚠️ **新信号必须继承这套变换**,否则会出现“预览是调整后的波形、构建出来的却按原始状态绘制” + //(用户报告的不一致):网格里的预览曲线绘制的是 `变换(滤波结果)`,而构建出的信号只存了 + // `滤波结果`;把变换一并继承后,两者绘制完全相同。 + // 另外**时间轴偏移只能靠继承**:派生信号与源信号共用同一个文件时间轴,无法把时间平移烘进数据。 + SignalStyle inheritedTransform; + { + SignalRef sourceRef; + sourceRef.fileIndex = request.fileIndex; + sourceRef.signalIndex = request.signalIndex; + if (const SignalStyle* sourceStyle = m_dataSet.Style(sourceRef)) + { + inheritedTransform.gain = sourceStyle->gain; + inheritedTransform.absolute = sourceStyle->absolute; + inheritedTransform.mirrorVertical = sourceStyle->mirrorVertical; + inheritedTransform.mirrorHorizontal = sourceStyle->mirrorHorizontal; + inheritedTransform.offsetY = sourceStyle->offsetY; + inheritedTransform.offsetTime = sourceStyle->offsetTime; + } + } + // 1. 计算派生采样值(与网格内的预览曲线共用同一实现,因此逐位一致)。 // 多次迭代均值滤波形如「<原信号名称> (N点均值 M次迭代)」, // 弱峰调整形如「<原信号名称> (弱峰调整 M% N次迭代)」。 @@ -1544,7 +1567,7 @@ namespace jdi std::to_wstring(request.iterations) + L"次迭代)"; } - // 2. 组装新信号。 + // 2. 组装新信号(外观先沿用默认值,插入后再继承源信号的「调整」变换)。 derived.rawHeader = derived.name; derived.unit = source.unit; derived.values.assign(derived_values.begin(), derived_values.end()); @@ -1563,6 +1586,18 @@ namespace jdi tab.RemapAfterSignalInsert(request.fileIndex, newIndex); } + // 3b. 新信号继承源信号的「调整」变换(系数 / 绝对值 / 镜像 / 偏移), + // 使其绘制结果与网格里的预览曲线**完全一致**。 + if (SignalStyle* derivedStyle = m_dataSet.Style(SignalRef{request.fileIndex, newIndex})) + { + derivedStyle->gain = inheritedTransform.gain; + derivedStyle->absolute = inheritedTransform.absolute; + derivedStyle->mirrorVertical = inheritedTransform.mirrorVertical; + derivedStyle->mirrorHorizontal = inheritedTransform.mirrorHorizontal; + derivedStyle->offsetY = inheritedTransform.offsetY; + derivedStyle->offsetTime = inheritedTransform.offsetTime; + } + // 4. 在当前标签页中勾选新信号,并把它所在的网格移到原信号网格的下方。 if (makeVisible && !m_tabs.empty()) { diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 3fc81f5..c71c6cb 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -1443,9 +1443,11 @@ namespace jdi * @param [IN] count 可用采样点数。 * @param [OUT] begin 区间起点(含)。 * @param [OUT] end 区间终点(不含)。 + * @param [IN] timeShift 信号的时间偏移:采样画在 `t + timeShift` 处,因此可见采样对应的 + * 源时刻区间为 `[ViewMin - timeShift, ViewMax - timeShift]`(偏移 0 时同视界)。 */ void VisibleIndexRange(const DataFile& file, std::size_t count, std::size_t& begin, - std::size_t& end) const; + std::size_t& end, double timeShift = 0.0) const; /** * @brief 绘制某条信号的多次迭代均值滤波曲线(与中心平均值曲线同一配色派生规则)。 @@ -1842,6 +1844,8 @@ namespace jdi bool absolute = false; /**< 绘制前是否取绝对值。 */ bool mirrorVertical = false; /**< 绘制前是否按 Y=0 上下镜像。 */ bool mirrorHorizontal = false; /**< 是否水平镜像(取值下标反序)。 */ + float offsetY = 0.0f; /**< Y 轴偏移。 */ + double offsetTime = 0.0; /**< 时间轴偏移(秒)。 */ std::vector points; /**< 折线顶点。 */ std::vector breaks; /**< 每段折线在 points 中的起始下标。 */ @@ -2009,6 +2013,8 @@ namespace jdi bool absolute = false; /**< 是否取绝对值。 */ bool mirrorVertical = false; /**< 是否按 Y=0 上下镜像。 */ bool mirrorHorizontal = false; /**< 是否水平镜像(数据反序)。 */ + float offsetY = 0.0f; /**< Y 轴偏移。 */ + double offsetTime = 0.0; /**< 时间轴偏移(秒)。 */ /** @brief 相等比较(用于判断"逐信号外观样式是否变化")。 */ bool operator==(const SceneStyleEntry& other) const @@ -2017,7 +2023,8 @@ namespace jdi lineWidth == other.lineWidth && mode == other.mode && gain == other.gain && absolute == other.absolute && mirrorVertical == other.mirrorVertical && - mirrorHorizontal == other.mirrorHorizontal; + mirrorHorizontal == other.mirrorHorizontal && offsetY == other.offsetY && + offsetTime == other.offsetTime; } }; diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index 94058c1..037b3ff 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -501,143 +501,6 @@ namespace jdi } } - /** - * @brief 时间间隔的显示单位。 - */ - struct DurationUnit - { - double scale = 1.0; /**< 1 个该单位等于多少秒。 */ - const wchar_t* name = L"s"; /**< 单位名称。 */ - }; - - /** - * @brief 把时间间隔格式化为"Δt 语义"的复合单位文本。 - * - * 规则(用户明确要求): - * - 顶级单位高于 s(即 h / min)时**一路整合到 s**,例如 3662s → "1h 1min 2s"; - * - 顶级单位为 s 及以下时,若不是该单位的整数,则**向下一级递进**, - * 例如 5.06s → "5s 60ms";整秒数不加下级单位,例如 5s → "5s"; - * - 顶级单位为 ps 时**不再递进次级单位**,例如 5.123ps → "5.123 ps"。 - * - * @param [IN] seconds 时间间隔(秒)。 - * @return 不含 "Δt" 前缀的间隔文本。 - */ - std::wstring FormatDurationCompound(double seconds) - { - if (!std::isfinite(seconds)) - { - return L"—"; - } - const double value = std::fabs(seconds); - if (value <= 0.0) - { - return L"0s"; - } - - // ---- 顶级单位高于 s(h / min):整合为 h/min/s(整数秒)---- - // 注意条件必须是 >= 60s:1~60s 属于"顶级单位为 s"的情况,需按下一条规则处理 - // (非整数秒要递进到 ms,例如 5.06s → "5s 60ms")。 - if (value >= 60.0) - { - const long long totalSeconds = static_cast(std::llround(value)); - long long hours = 0; - long long minutes = 0; - long long secs = totalSeconds; - if (totalSeconds >= 3600) - { - hours = totalSeconds / 3600; - secs = totalSeconds % 3600; - } - if (secs >= 60) - { - minutes = secs / 60; - secs = secs % 60; - } - - std::wstring text; - const auto append = [&text](long long amount, const wchar_t* unit) - { - if (amount <= 0) - { - return; - } - if (!text.empty()) - { - text += L" "; - } - text += std::to_wstring(amount); - text += unit; - }; - append(hours, L"h"); - append(minutes, L"min"); - append(secs, L"s"); - if (!text.empty()) - { - return text; - } - } - - // ---- 顶级单位为 s:整数秒 + 可选 ms ---- - if (value >= 1.0) - { - const double whole = std::floor(value); - const double fraction = value - whole; - std::wstring text = FormatValue(whole) + L"s"; - const double remainderMs = fraction * 1000.0; - if (remainderMs >= 1.0e-6) - { - text += L" "; - text += FormatValue(remainderMs); - text += L"ms"; - } - return text; - } - - // ---- 顶级单位低于 s:ms / us / ns / ps ---- - const DurationUnit subUnits[] = { - {1.0e-3, L"ms"}, {1.0e-6, L"us"}, {1.0e-9, L"ns"}, {1.0e-12, L"ps"}, - }; - const std::size_t subCount = sizeof(subUnits) / sizeof(subUnits[0]); - std::size_t index = subCount - 1; - for (std::size_t i = 0; i < subCount; ++i) - { - if (value >= subUnits[i].scale) - { - index = i; - break; - } - } - - // 顶级单位为 ps:不再递进次级单位(保留小数)。 - if (index + 1 >= subCount) - { - return FormatValue(value / subUnits[index].scale) + L" " + subUnits[index].name; - } - - const double scaled = value / subUnits[index].scale; - double whole = std::floor(scaled); - double fraction = scaled - whole; - // 浮点误差可能导致 fraction 极接近 1(例如 4.9999999),归一化一下。 - if (fraction >= 1.0 - 1.0e-9) - { - whole += 1.0; - fraction = 0.0; - } - - std::wstring text = FormatValue(whole); - text += subUnits[index].name; - // 仍是该单位的整数 → 不显示下级单位。 - const double remainder = fraction * subUnits[index].scale; - const double inNext = remainder / subUnits[index + 1].scale; - if (inNext >= 1.0e-6) - { - text += L" "; - text += FormatValue(inNext); - text += subUnits[index + 1].name; - } - return text; - } - /** * @brief 双光标差值的完整显示文本("Δt:" + 复合单位间隔)。 * @param [IN] seconds 时间间隔(秒)。 @@ -662,23 +525,6 @@ namespace jdi return FormatValue(seconds) + L"s"; } - /** - * @brief 把时间间隔格式化为"秒"数值文本(不含单位),用于内联编辑。 - * - * 编辑时的量级**始终是秒**:即使显示为 "1.5min",双击编辑的内容也是 90。 - * - * @param [IN] seconds 时间间隔(秒)。 - * @return 形如 "60" 的文本。 - */ - std::wstring FormatDurationSeconds(double seconds) - { - if (!std::isfinite(seconds)) - { - return L"0"; - } - return FormatValue(std::fabs(seconds)); - } - /** * @brief 按比例缩放颜色的 alpha 通道(用于提示浮层淡出)。 * @param [IN] argb 原始 0xAARRGGBB 颜色。 @@ -2204,7 +2050,7 @@ namespace jdi } void PlotView::VisibleIndexRange(const DataFile& file, std::size_t count, std::size_t& begin, - std::size_t& end) const + std::size_t& end, double timeShift) const { begin = 0; end = count; @@ -2212,15 +2058,16 @@ namespace jdi { return; } + // 时间偏移:采样画在 `t + timeShift` 处,因此落在当前视界内的采样其源时刻区间要整体左移。 const std::vector& time = file.time; begin = static_cast( - std::lower_bound(time.begin(), time.end(), Tab().ViewMin()) - time.begin()); + std::lower_bound(time.begin(), time.end(), Tab().ViewMin() - timeShift) - time.begin()); if (begin > 0) { --begin; } end = static_cast( - std::lower_bound(time.begin(), time.end(), Tab().ViewMax()) - time.begin()); + std::lower_bound(time.begin(), time.end(), Tab().ViewMax() - timeShift) - time.begin()); if (end < count) { ++end; @@ -3202,19 +3049,24 @@ namespace jdi } const std::size_t count = std::min(file->time.size(), signal->values.size()); + const SignalStyle* style = m_dataSet->Style(ref); + // 时间偏移:可见采样对应的源时刻区间要整体左移(画在 t + shift 处)。 + const double timeShift = (style != nullptr) ? style->offsetTime : 0.0; + const double sourceMin = viewMin - timeShift; + const double sourceMax = viewMax - timeShift; std::size_t begin = 0; std::size_t end = count; if (file->timeMonotonic) { // 时间单调时直接二分定位区间,百万级采样也只需扫过可见段。 begin = static_cast( - std::lower_bound(file->time.begin(), file->time.end(), viewMin) - file->time.begin()); + std::lower_bound(file->time.begin(), file->time.end(), sourceMin) - file->time.begin()); if (begin > 0) { --begin; } end = static_cast( - std::lower_bound(file->time.begin(), file->time.end(), viewMax) - file->time.begin()); + std::lower_bound(file->time.begin(), file->time.end(), sourceMax) - file->time.begin()); if (end < count) { ++end; @@ -3226,7 +3078,7 @@ namespace jdi if (!file->timeMonotonic) { const double t = file->time[index]; - if (!std::isfinite(t) || t < viewMin || t > viewMax) + if (!std::isfinite(t) || t < sourceMin || t > sourceMax) { continue; } @@ -5355,11 +5207,13 @@ 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 mirrorVertical = (resolved.style != nullptr) && resolved.style->mirrorVertical; const bool mirrorHorizontal = (resolved.style != nullptr) && resolved.style->mirrorHorizontal; + const float offsetY = (resolved.style != nullptr) ? resolved.style->offsetY : 0.0f; + const double offsetTime = (resolved.style != nullptr) ? resolved.style->offsetTime : 0.0; for (const PolylineCacheEntry& entry : m_polylineCache) { @@ -5368,7 +5222,8 @@ 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.mirrorVertical == mirrorVertical && entry.mirrorHorizontal == mirrorHorizontal) + entry.mirrorVertical == mirrorVertical && entry.mirrorHorizontal == mirrorHorizontal && + entry.offsetY == offsetY && entry.offsetTime == offsetTime) { return &entry; } @@ -5397,6 +5252,8 @@ namespace jdi entry.absolute = absolute; entry.mirrorVertical = mirrorVertical; entry.mirrorHorizontal = mirrorHorizontal; + entry.offsetY = offsetY; + entry.offsetTime = offsetTime; BuildPolyline(resolved, lane, entry.points, entry.breaks, entry.band, entry.bandBreaks, entry.bandMode, entry.bandSpiky); @@ -5432,18 +5289,24 @@ namespace jdi const double viewMin = Tab().ViewMin(); const double viewMax = Tab().ViewMax(); + // 时间偏移:采样画在 `t + offsetTime` 处,因此“落在当前视界内的采样” + // 其源时刻位于 `[viewMin - offsetTime, viewMax - offsetTime]`。 + const double timeShift = resolved.style->offsetTime; + const double sourceMin = viewMin - timeShift; + const double sourceMax = viewMax - timeShift; + std::size_t begin = 0; std::size_t end = count; if (resolved.file->timeMonotonic) { - const std::vector::const_iterator first = std::lower_bound(time.begin(), time.end(), viewMin); + const std::vector::const_iterator first = std::lower_bound(time.begin(), time.end(), sourceMin); begin = static_cast(first - time.begin()); if (begin > 0) { --begin; } - const std::vector::const_iterator last = std::lower_bound(time.begin(), time.end(), viewMax); + const std::vector::const_iterator last = std::lower_bound(time.begin(), time.end(), sourceMax); end = static_cast(last - time.begin()); if (end < count) { @@ -5497,8 +5360,9 @@ namespace jdi { const float marginPx = std::max(4.0f, std::max(1.0f, resolved.style->lineWidth) * m_scale + 3.0f); - const double ta = XToTime(m_stripX0 - marginPx); - const double tb = XToTime(m_stripX1 + marginPx); + // 条带边界是**绘制时刻**,换算成源时刻要减去时间偏移。 + const double ta = XToTime(m_stripX0 - marginPx) - timeShift; + const double tb = XToTime(m_stripX1 + marginPx) - timeShift; const double clipLo = std::min(ta, tb); const double clipHi = std::max(ta, tb); std::size_t sBegin = static_cast( @@ -5546,13 +5410,16 @@ namespace jdi const std::vector& coarseMin = resolved.data->coarseMin; const std::vector& coarseMax = resolved.data->coarseMax; const std::size_t coarseStride = resolved.data->coarseStride; - // ⚠️ 粗粒度分组只存了逐组的 min/max:取绝对值 / 中心反转会把两者互换, + // ⚠️ 粗粒度分组只存了逐组的 min/max:取绝对值 / 垂直镜像会把两者互换, // 此时分组层无法再给出正确的包络,必须回退到逐采样点变换(见 `DrawTransformOrderPreserving`)。 + // Y 偏移是保序的(整体加常数),因此仍可用分组层 —— 只需把偏移一并加上。 const bool hasCoarse = (coarseStride > 0) && !coarseMin.empty() && (coarseMin.size() == coarseMax.size()) && DrawTransformOrderPreserving(*resolved.style); - // 保序变换(正增益)下,分组极值直接乘增益即为变换后的极值(gain==1 时逐位不变)。 + // 保序变换(正系数 + Y 偏移)下:分组极值 × 系数 + 偏移 即为变换后的极值 + //(系数 1、偏移 0 时逐位不变)。 const double coarseScale = static_cast(resolved.style->gain); + const double coarseOffset = static_cast(resolved.style->offsetY); bool inSegment = false; @@ -5575,21 +5442,24 @@ namespace jdi std::size_t searchFrom = begin; for (std::size_t column = 0; column < columns; ++column) { + // `t0/t1` 是**绘制时刻**的列边界;源时刻要减去时间偏移(偏移为 0 时二者相同)。 const double t0 = viewMin + viewSpan * (static_cast(column) / static_cast(columns)); const double t1 = viewMin + viewSpan * (static_cast(column + 1) / static_cast(columns)); + const double s0 = t0 - timeShift; + const double s1 = t1 - timeShift; // 列边界随 column 单调递增,从上一列的位置继续二分, // 把每次搜索的范围压到剩余区间,避免 2400 次全区间二分带来的缓存缺失。 const std::size_t i0 = static_cast( - std::lower_bound(time.begin() + static_cast(searchFrom), rangeEnd, t0) - + std::lower_bound(time.begin() + static_cast(searchFrom), rangeEnd, s0) - time.begin()); searchFrom = i0; const std::size_t i1 = (column + 1 == columns) ? end : static_cast( std::lower_bound(time.begin() + static_cast(i0), - rangeEnd, t1) - + rangeEnd, s1) - time.begin()); double low = std::numeric_limits::infinity(); @@ -5611,8 +5481,8 @@ namespace jdi std::min(srcB / coarseStride, coarseMin.size() - 1); for (std::size_t bucket = firstBucket; bucket <= lastBucket; ++bucket) { - const double bucketLow = coarseMin[bucket] * coarseScale; - const double bucketHigh = coarseMax[bucket] * coarseScale; + const double bucketLow = coarseMin[bucket] * coarseScale + coarseOffset; + const double bucketHigh = coarseMax[bucket] * coarseScale + coarseOffset; if (!std::isfinite(bucketLow)) { // 该分组全部是无效值:包络到此断开,后续另起一段。 @@ -5667,7 +5537,7 @@ namespace jdi // 其极值必然出现在采样点或列边界上,因此「列内采样点 + 两侧边界取值」就是 // 该列包络的精确解,且相邻两列在边界处取到同一个值,包络带严丝合缝。 double boundary = 0.0; - if (PolylineValueAt(time, values, count, *resolved.style, i0, t0, boundary)) + if (PolylineValueAt(time, values, count, *resolved.style, i0, s0, boundary)) { boundary = ApplyDrawTransform(*resolved.style, boundary); if (boundary < low) @@ -5681,7 +5551,7 @@ namespace jdi highOrder = (i0 > 0) ? SourceIndexFor(*resolved.style, count, i0 - 1) : 0; } } - if (PolylineValueAt(time, values, count, *resolved.style, i1, t1, boundary)) + if (PolylineValueAt(time, values, count, *resolved.style, i1, s1, boundary)) { boundary = ApplyDrawTransform(*resolved.style, boundary); const std::size_t order = SourceIndexFor(*resolved.style, count, (i1 > 0) ? (i1 - 1) : 0); @@ -5779,7 +5649,8 @@ namespace jdi continue; } - const float x = TimeToX(t); + // 时间偏移:该采样画在 `t + offsetTime` 处(例如 t=1s、偏移 -0.5s → 画在 0.5s)。 + const float x = TimeToX(t + timeShift); float y = ValueToY(value, lane); y = static_cast(ClampDouble(y, limitTop, limitBottom)); @@ -6441,6 +6312,9 @@ namespace jdi mix(resolved.style->absolute ? 1ull : 0ull); mix(resolved.style->mirrorVertical ? 1ull : 0ull); mix(resolved.style->mirrorHorizontal ? 1ull : 0ull); + // 偏移(Y 平移 + 时间平移)同样会改变波形。 + mixFloat(resolved.style->offsetY); + mixFloat(static_cast(resolved.style->offsetTime)); } // 图例文案参与绘制,重命名必须让缓存失效。 mixText(resolved.data->Title()); @@ -6490,6 +6364,8 @@ namespace jdi entry.absolute = resolved.style->absolute; entry.mirrorVertical = resolved.style->mirrorVertical; entry.mirrorHorizontal = resolved.style->mirrorHorizontal; + entry.offsetY = resolved.style->offsetY; + entry.offsetTime = resolved.style->offsetTime; } lane.push_back(entry); } @@ -7348,6 +7224,14 @@ 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->mirrorVertical ? 1ull : 0ull); + mix(resolved.style->mirrorHorizontal ? 1ull : 0ull); + mixFloat(resolved.style->offsetY); + mixFloat(static_cast(resolved.style->offsetTime)); } return hash; } @@ -10331,7 +10215,7 @@ namespace jdi std::size_t begin = 0; std::size_t end = 0; - VisibleIndexRange(*resolved.file, count, begin, end); + VisibleIndexRange(*resolved.file, count, begin, end, resolved.style->offsetTime); if (end <= begin) { return; @@ -10387,7 +10271,8 @@ namespace jdi } float y = ValueToY(ApplyDrawTransform(*resolved.style, static_cast(sampled)), lane); y = static_cast(ClampDouble(y, limitTop, limitBottom)); - const float x = TimeToX(time[index]); + // 时间偏移:该采样画在 `t + offsetTime` 处。 + const float x = TimeToX(time[index] + resolved.style->offsetTime); if (!inSegment) { breaks.push_back(points.size()); diff --git a/Components/SignalPanel/Inc/SignalPanel.hpp b/Components/SignalPanel/Inc/SignalPanel.hpp index 820d2e1..8062572 100644 --- a/Components/SignalPanel/Inc/SignalPanel.hpp +++ b/Components/SignalPanel/Inc/SignalPanel.hpp @@ -36,7 +36,10 @@ namespace jdi CMD_LANE_BASE = 300, /**< 网格归属基准(配合下标)。 */ CMD_LANE_NEW = 400, /**< 新建网格。 */ CMD_GAIN_EDIT = 500, /**< 「系数」二级面板里的系数输入框(幂等提交)。 */ - CMD_GAIN_RESET = 501, /**< 「系数」二级面板标题栏右侧的还原按钮:把系数重置为 1。 */ + CMD_GAIN_RESET = 501, /**< 「系数调整」标题栏右侧的还原按钮:把系数重置为 1。 */ + CMD_OFFSET_RESET = 502, /**< 「偏移调整」标题栏右侧的还原按钮:两个偏移都恢复为默认值。 */ + CMD_OFFSET_Y = 503, /**< 「偏移调整」:Y 轴偏移输入框。 */ + CMD_OFFSET_TIME = 504, /**< 「偏移调整」:时间轴偏移输入框。 */ CMD_MIRROR_HORIZONTAL = 510, /**< 「镜像」二级面板:水平镜像(数据反序)。 */ CMD_MIRROR_VERTICAL = 511 /**< 「镜像」二级面板:垂直镜像(按 Y=0 上下翻转)。 */ }; diff --git a/Components/SignalPanel/Src/SignalPanel.cpp b/Components/SignalPanel/Src/SignalPanel.cpp index f86ba84..6e48cdd 100644 --- a/Components/SignalPanel/Src/SignalPanel.cpp +++ b/Components/SignalPanel/Src/SignalPanel.cpp @@ -167,18 +167,18 @@ namespace jdi } /** - * @brief 把系数格式化为界面文本:**至少两位小数**,精度更高时按实际精度显示。 + * @brief 数值文本:**至少两位小数**,精度更高时按实际精度显示。 * * 例:`1` → `1.00`、`2.5` → `2.50`、`0.125` → `0.125`、`1.2345678` → `1.2345678`。 - * 实现:从 2 位小数开始递增位数,直到文本能精确表示该系数(相对误差 1e-7,与 float 精度匹配)。 + * 实现:从 2 位小数开始递增位数,直到文本能精确表示该数值 + *(容差 1e-7 相对量,与 float 精度匹配)。 * - * @param [IN] gain 系数。 + * @param [IN] value 数值。 * @return 显示文本。 */ - std::wstring FormatGainText(float gain) + std::wstring FormatNumberText(double value) { - const double value = static_cast(gain); - const double tolerance = std::max(1.0e-9, std::fabs(value) * 1.0e-7); + const double tolerance = std::max(1.0e-12, std::fabs(value) * 1.0e-7); for (int decimals = 2; decimals <= 9; ++decimals) { const std::wstring text = jdi::FormatNumber(value, decimals); @@ -190,6 +190,27 @@ namespace jdi } return jdi::FormatNumber(value, 9); } + + /** + * @brief 带正负符号的数值文本:正数也带 `+`(非编辑态显示偏移量时使用)。 + * @param [IN] value 数值。 + * @return 形如 `+1.50` / `-0.25` / `+0.00` 的文本。 + */ + std::wstring FormatSignedNumber(double value) + { + const std::wstring text = FormatNumberText(std::fabs(value)); + return ((value < 0.0) ? L"-" : L"+") + text; + } + + /** + * @brief 把系数格式化为界面文本(与偏移量同一套两位小数规则)。 + * @param [IN] gain 系数。 + * @return 显示文本。 + */ + std::wstring FormatGainText(float gain) + { + return FormatNumberText(static_cast(gain)); + } } SignalPanel::SignalPanel() @@ -1291,8 +1312,9 @@ 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); + // 系数/偏移:系数不等于默认值 1、或任一偏移不为 0 时显示为"已启用"。 + paintToggle(kAdjustGain, L"系数/偏移", + !GainIsDefault(style->gain) || style->Offset(), StyleHit::GAIN_ADJUST); // 镜像:水平或垂直任一被勾选即显示为激活态。 paintToggle(kAdjustMirror, L"镜像", style->Mirrored(), StyleHit::MIRROR_ADJUST); paintToggle(kAdjustAbsolute, L"绝对值", style->absolute, StyleHit::ABSOLUTE_TOGGLE); @@ -1977,23 +1999,56 @@ namespace jdi return false; } - // 二级面板:标题行(右侧带「还原」图标按钮)+ 一整行系数输入框 - //(与网格 Y 轴上下限的菜单内联编辑同一套做法:提交后面板保持打开,可连续微调)。 + // 二级面板:两张“标题(带还原图标)+ 输入框”的组合: + // 1. 「系数调整」:一整行系数输入框; + // 2. 「偏移调整」:下方两行分别为「Y轴」与「时间轴」的偏移输入框。 + //(与网格 Y 轴上下限的菜单内联编辑同一套做法:提交后面板保持打开,可连续微调。) std::vector items; - MenuItem header; - header.kind = MenuItemKind::HEADER_ACTION; - header.label = L"系数调整"; - header.id = CMD_GAIN_RESET; // 点击标题栏右侧的还原图标 → 系数重置为 1 - items.push_back(header); + MenuItem gainHeader; + gainHeader.kind = MenuItemKind::HEADER_ACTION; + gainHeader.label = L"系数调整"; + gainHeader.id = CMD_GAIN_RESET; // 点击标题栏右侧的还原图标 → 系数重置为 1 + items.push_back(gainHeader); - MenuItem edit; - edit.kind = MenuItemKind::EDIT; - edit.label.clear(); // 不再显示「绘制系数」说明文字:输入框独占整行 - edit.id = CMD_GAIN_EDIT; - edit.value = FormatGainText(style->gain); - edit.filter = EditFilter::NUMBER; - items.push_back(edit); + const std::wstring gainText = FormatGainText(style->gain); + MenuItem gainEdit; + gainEdit.kind = MenuItemKind::EDIT; + gainEdit.label.clear(); // 不显示说明文字:输入框独占整行 + gainEdit.id = CMD_GAIN_EDIT; + gainEdit.value = gainText; + gainEdit.editValue = gainText; + gainEdit.filter = EditFilter::NUMBER; + items.push_back(gainEdit); + + MenuItem offsetHeader; + offsetHeader.kind = MenuItemKind::HEADER_ACTION; + offsetHeader.label = L"偏移调整"; + offsetHeader.id = CMD_OFFSET_RESET; // 点击还原图标 → 两个偏移都恢复默认值 + items.push_back(offsetHeader); + + // 「Y轴」:对绘制值加上一个常数偏移(波形上下移动),非编辑态带正负号。 + MenuItem offsetY; + offsetY.kind = MenuItemKind::EDIT; + offsetY.label = L"Y轴"; + offsetY.id = CMD_OFFSET_Y; + offsetY.value = FormatSignedNumber(static_cast(style->offsetY)); + offsetY.editValue = FormatNumberText(static_cast(style->offsetY)); + offsetY.filter = EditFilter::NUMBER; + items.push_back(offsetY); + + // 「时间轴」:把采样沿时间轴平移(画在 t + offsetTime)。 + // 非编辑态显示与双光标差值同一套复合单位文本;编辑态显示以秒为单位的数值, + // 并在输入框最右侧提示单位 `s`。 + MenuItem offsetTime; + offsetTime.kind = MenuItemKind::EDIT; + offsetTime.label = L"时间轴"; + offsetTime.id = CMD_OFFSET_TIME; + offsetTime.value = L"Δt:" + FormatDurationCompound(style->offsetTime); + offsetTime.editValue = FormatNumberText(style->offsetTime); + offsetTime.editSuffix = L"s"; + offsetTime.filter = EditFilter::NUMBER; + items.push_back(offsetTime); // 锚定在按钮下沿:面板紧贴按钮展开(与拾色器"以触发器矩形为锚点"的观感一致)。 const gd::RectF& button = m_adjustRects[kAdjustGain]; @@ -2820,30 +2875,75 @@ namespace jdi return false; } - // 「系数」二级面板里的输入框提交:就地更新绘制系数,面板保持打开 + // 「系数」/「偏移」二级面板里的输入框提交:就地更新,面板保持打开 //(与网格 Y 轴上下限的菜单内联编辑一致),并把夹取后的真实值回写到输入框上。 - if (result.editCommit && result.id == CMD_GAIN_EDIT) + if (result.editCommit) { SignalStyle* style = StyleOf(SelectedRef()); if (style != nullptr) { - wchar_t* stop = nullptr; const std::wstring trimmed = Trim(result.editText); - const double parsed = std::wcstod(trimmed.c_str(), &stop); + wchar_t* stop = nullptr; + const double parsed = + trimmed.empty() ? 0.0 : std::wcstod(trimmed.c_str(), &stop); // 非法/空输入保持原值(只把真实值回写到输入框,避免用户看到错误的数字)。 - if (!trimmed.empty() && stop != trimmed.c_str() && std::isfinite(parsed)) + const bool valid = !trimmed.empty() && stop != trimmed.c_str() && std::isfinite(parsed); + switch (result.id) { - style->gain = static_cast(std::max(-kGainLimit, std::min(parsed, kGainLimit))); + case CMD_GAIN_EDIT: + { + if (valid) + { + style->gain = + static_cast(std::max(-kGainLimit, std::min(parsed, kGainLimit))); + } + if (m_menu != nullptr) + { + m_menu->UpdateItemValue(CMD_GAIN_EDIT, FormatGainText(style->gain), FormatGainText(style->gain)); + } + return false; } - if (m_menu != nullptr) + case CMD_OFFSET_Y: { - m_menu->UpdateItemValue(CMD_GAIN_EDIT, FormatGainText(style->gain)); + if (valid) + { + style->offsetY = static_cast( + std::max(-static_cast(kGainLimit), std::min(parsed, kGainLimit))); + } + if (m_menu != nullptr) + { + m_menu->UpdateItemValue( + CMD_OFFSET_Y, FormatSignedNumber(static_cast(style->offsetY)), + FormatNumberText(static_cast(style->offsetY))); + } + return false; + } + case CMD_OFFSET_TIME: + { + if (valid) + { + style->offsetTime = + std::max(-kGainLimit, std::min(parsed, kGainLimit)); + } + if (m_menu != nullptr) + { + m_menu->UpdateItemValue(CMD_OFFSET_TIME, + L"Δt:" + FormatDurationCompound(style->offsetTime), + FormatNumberText(style->offsetTime)); + } + return false; + } + default: + break; } } - return false; + else + { + return false; + } } - // 标题栏右侧的「还原」图标:把系数重置为默认值 1(面板保持打开,输入框立即回显)。 + // 标题栏右侧的「还原」图标:系数重置为默认值 1(面板保持打开,输入框立即回显)。 if (result.id == CMD_GAIN_RESET) { if (SignalStyle* style = StyleOf(SelectedRef())) @@ -2851,7 +2951,24 @@ namespace jdi style->gain = 1.0f; if (m_menu != nullptr) { - m_menu->UpdateItemValue(CMD_GAIN_EDIT, FormatGainText(style->gain)); + m_menu->UpdateItemValue(CMD_GAIN_EDIT, FormatGainText(style->gain), FormatGainText(style->gain)); + } + } + return false; + } + + // 「偏移调整」的「还原」图标:Y 与时间偏移都恢复为默认值 0。 + if (result.id == CMD_OFFSET_RESET) + { + if (SignalStyle* style = StyleOf(SelectedRef())) + { + style->offsetY = 0.0f; + style->offsetTime = 0.0; + if (m_menu != nullptr) + { + m_menu->UpdateItemValue(CMD_OFFSET_Y, FormatSignedNumber(0.0), L"0.00"); + m_menu->UpdateItemValue(CMD_OFFSET_TIME, L"Δt:" + FormatDurationCompound(0.0), + L"0.00"); } } return false; @@ -2865,6 +2982,18 @@ namespace jdi const bool horizontal = (result.id == CMD_MIRROR_HORIZONTAL); bool& flag = horizontal ? style->mirrorHorizontal : style->mirrorVertical; flag = !flag; + if (!horizontal) + { + // ⚠️ Y 偏移以**未镜像空间**为准:垂直镜像开关变化时同步取反偏移量, + // 使绘制等价于「先加上偏移再镜像」(用户明确要求)。 + style->offsetY = -style->offsetY; + if (m_menu != nullptr) + { + m_menu->UpdateItemValue( + CMD_OFFSET_Y, FormatSignedNumber(static_cast(style->offsetY)), + FormatNumberText(static_cast(style->offsetY))); + } + } if (m_menu != nullptr) { m_menu->UpdateItemLabel(result.id, horizontal ? L"水平镜像" : L"垂直镜像", flag, true); diff --git a/Utilities/Inc/DataModel.hpp b/Utilities/Inc/DataModel.hpp index ce59bbf..e725517 100644 --- a/Utilities/Inc/DataModel.hpp +++ b/Utilities/Inc/DataModel.hpp @@ -107,35 +107,51 @@ namespace jdi DrawMode mode = DrawMode::LINE; /**< 绘制方式。 */ /** - * 绘制前的数值变换(依次作用,见 `ApplyDrawTransform`): + * 绘制前的变换(依次作用,见 `ApplyDrawTransform`): * 1. `gain` 系数:采样值乘以该系数(默认 1,即不改变); * 2. `absolute` 绝对值:|value|; * 3. 镜像:`mirrorVertical` 按 Y = 0 上下镜像(value → −value), - * `mirrorHorizontal` 为**数据反序**(把采样序列头尾对调后绘制,即水平翻转)。 + * `mirrorHorizontal` 为**数据反序**(把采样序列头尾对调后绘制,即水平翻转); + * 4. 偏移:`offsetY` 给绘制值加上一个常数(波形整体上下移动), + * `offsetTime` 把采样沿时间轴平移(绘制在 `t + offsetTime` 处)。 * - * ⚠️ 结算顺序固定为「系数 → 绝对值 → 镜像」(用户明确要求), - * 其中镜像的两个方向互相正交:垂直改**值**、水平改**取值下标**。 + * ⚠️ 结算顺序固定为「系数 → 绝对值 → 镜像 → 偏移」(用户明确要求)。 + * 其中镜像的两个方向互相正交(垂直改**值**、水平改**取值下标**),偏移同理 + *(`offsetY` 改值、`offsetTime` 改**绘制时刻**)。 + * ⚠️ `offsetY` 以**未镜像空间**为准:启用/取消垂直镜像时会同步取反(见 + * `SignalStyle::offsetY` 的使用处),使绘制等价于「先加上偏移再镜像」。 */ float gain = 1.0f; /**< 系数,默认 1.0(不改变)。 */ bool absolute = false; /**< 是否对绘制值取绝对值,默认关闭。 */ bool mirrorVertical = false; /**< 是否按 Y=0 上下镜像(值取反),默认关闭。 */ bool mirrorHorizontal = false; /**< 是否水平镜像(数据反序绘制),默认关闭。 */ + float offsetY = 0.0f; /**< Y 轴偏移(叠加到绘制值上),默认 0。 */ + double offsetTime = 0.0; /**< 时间轴偏移(秒,绘制在 t + offsetTime 处),默认 0。 */ /** @brief 镜像功能的任一分量是否启用(决定「镜像」按钮的激活态)。 */ bool Mirrored() const { return mirrorVertical || mirrorHorizontal; } + + /** + * @brief 是否存在任何**偏移**(决定「系数/偏移」按钮的激活态之一)。 + * @return Y 或时间偏移不为 0 返回 true。 + */ + bool Offset() const + { + return std::fabs(offsetY) > 1.0e-9f || std::fabs(offsetTime) > 1.0e-15; + } }; /** * @brief 把原始采样值变换为**绘制用值**。 * - * 顺序固定为「系数 → 绝对值 → 镜像(垂直分量)」,例如系数 2、原始值 1.5 → - * 2×1.5 = 3;再取绝对值仍为 3;若开启垂直镜像则为 −3。 - * NaN 经乘/取绝对值/取反后仍为 NaN(保持断线语义)。 + * 顺序固定为「系数 → 绝对值 → 垂直镜像 → Y 偏移」,例如系数 2、原始值 1.5 → + * 2×1.5 = 3;取绝对值仍为 3;垂直镜像则 −3;Y 偏移 +1 得 −2。 + * NaN 经乘/取绝对值/取反/加偏移后仍为 NaN(保持断线语义)。 * - * ⚠️ 水平镜像**不在这里处理**:它是"取值下标反序"(把采样序列头尾对调), - * 必须在遍历采样时按 `SourceIndexFor(style, count, index)` 取源下标。 + * ⚠️ 水平镜像与时间偏移**不在这里处理**:它们分别改变“取值下标”与“绘制时刻”, + * 必须在遍历采样时处理(见 `SourceIndexFor` 与 `offsetTime` 的使用处)。 * - * @param [IN] style 信号外观(提供系数与两个数值开关)。 + * @param [IN] style 信号外观。 * @param [IN] value 原始采样值。 * @return 绘制用值。 */ @@ -153,6 +169,10 @@ namespace jdi { value = -value; } + if (style.offsetY != 0.0f) + { + value += static_cast(style.offsetY); + } return value; } @@ -193,9 +213,9 @@ namespace jdi /** * @brief 把**数值区间**按绘制变换映射为变换后的区间(用于整段 Y 轴自适应)。 * - * 三个变换作用在区间端点上是精确的(系数为线性、绝对值为 |x|、镜像为线性), + * 各步作用在区间端点上是精确的(系数为线性、绝对值为 |x|、镜像与偏移为线性), * 因此 `[minValue, maxValue]` 映射后就是真实的数据范围,无需扫描采样点。 - * ⚠️ 水平镜像不改变值的范围,因此不影响本函数。 + * ⚠️ 水平镜像与时间偏移都不改变值的范围,因此不影响本函数。 * * @param [IN] style 信号外观。 * @param [IN,OUT] low 区间下限,返回变换后的下限。 @@ -219,7 +239,7 @@ namespace jdi } if (style.absolute) { - // |x| 在 [low, high] 上的取值范围:跨 0 时从 0 起,否则从较近的一端起。 + // |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); @@ -232,6 +252,12 @@ namespace jdi low = -high; high = flipped; } + if (style.offsetY != 0.0f) + { + const double offset = static_cast(style.offsetY); + low += offset; + high += offset; + } } /** @@ -696,12 +722,14 @@ namespace jdi double& value); /** - * @brief 取某一时刻**画面上显示**的取值(含水平镜像的"数据反序")。 + * @brief 取某一时刻**画面上显示**的取值(含水平镜像的"数据反序"与时间偏移)。 * - * 与 `SampleSignalAt` 的唯一区别:水平镜像启用时用 `SourceIndexFor` 把下标映射到源采样 - *(头尾对调),因此返回的正是当前绘制出来的那条曲线在该时刻的取值 —— 光标读数、 - * 数据点标注都必须用它,否则读数会与看到的波形不一致。 - * ⚠️ 系数 / 绝对值 / 垂直镜像不在这里处理,由 `ApplyDrawTransform` 负责。 + * 与 `SampleSignalAt` 的区别: + * * 水平镜像时用 `SourceIndexFor` 把下标映射到源采样(头尾对调); + * * 时间偏移时先按 `time - style.offsetTime` 求出源采样时刻(因为绘制在 `t + offsetTime`)。 + * 因此返回的正是当前绘制出来的那条曲线在该时刻的取值 —— 光标读数、数据点标注都必须用它, + * 否则读数会与看到的波形不一致。 + * ⚠️ 系数 / 绝对值 / 垂直镜像 / `offsetY` 不在这里处理,由 `ApplyDrawTransform` 负责。 * * @param [IN] file 信号所属的已加载数据分组(提供时间轴)。 * @param [IN] signal 目标信号。 diff --git a/Utilities/Inc/PopupMenu.hpp b/Utilities/Inc/PopupMenu.hpp index aa3f0d7..0f2e56e 100644 --- a/Utilities/Inc/PopupMenu.hpp +++ b/Utilities/Inc/PopupMenu.hpp @@ -55,6 +55,19 @@ namespace jdi std::uint32_t swatches[16] = {}; /**< 色块颜色。 */ int checkedSwatch = -1; /**< 被选中的色块下标。 */ std::wstring value; /**< EDIT 项的当前数值文本(非编辑态显示)。 */ + /** + * EDIT 项**进入编辑时的初始文本**;为空则使用 `value`。 + * + * 用于「非编辑态的显示文本」与「编辑态的数值」不一致的场合,例如时间轴偏移: + * 平时显示 `Δt:5s 60ms`,进入编辑后应显示以秒为单位的数值 `5.06`。 + */ + std::wstring editValue; + /** + * EDIT 项**编辑态**在输入框最右侧提示的单位文本(如 `s`);为空则不提示。 + * + * 非编辑态不显示(那时显示的是 `value` 的完整文案)。 + */ + std::wstring editSuffix; EditFilter filter = EditFilter::ANY; /**< EDIT 项的输入过滤规则。 */ std::vector submenu; /**< 二级子菜单项(kind == SUBMENU 时有效)。 */ /** @@ -231,10 +244,14 @@ namespace jdi /** * @brief 更新指定命令项显示的值文本(菜单保持打开,立即重新排布)。 * @param [IN] id 命令标识。 - * @param [IN] value 新的值文本。 + * @param [IN] value 新的值文本(非编辑态显示)。 + * @param [IN] editValue 新的**编辑初值**(为空时保持原值)。 + * ⚠️ 非编辑态文本与编辑态文本不一致的行(如时间轴偏移:`Δt:1h 1min 2s` ↔ `3662`) + * 必须一并更新,否则下次进入编辑会拿到陈旧的初值。 * @return 找到对应项并更新返回 true。 */ - bool UpdateItemValue(int id, const std::wstring& value); + bool UpdateItemValue(int id, const std::wstring& value, + const std::wstring& editValue = std::wstring()); /** * @brief 更新指定普通项的文字与可用/选中状态(用于实时反映状态)。 @@ -291,6 +308,17 @@ namespace jdi */ gd::RectF EditFieldRect(int index) const; + /** + * @brief 计算 EDIT 项**编辑态单位后缀**所占的宽度(0 表示无后缀)。 + * + * 排版、命中测试与绘制共用:输入框正文区会向右让出这么多宽度, + * 后缀文本绘制在它右侧(靠右对齐)。 + * + * @param [IN] item 菜单项。 + * @return 后缀宽度(像素)。 + */ + float EditSuffixWidth(const MenuItem& item) const; + /** * @brief 计算 `HEADER_ACTION` 项右侧**图标按钮**(如「还原」)的矩形。 * diff --git a/Utilities/Inc/Utf8Util.hpp b/Utilities/Inc/Utf8Util.hpp index 5e3ab18..7def786 100644 --- a/Utilities/Inc/Utf8Util.hpp +++ b/Utilities/Inc/Utf8Util.hpp @@ -97,6 +97,30 @@ namespace jdi */ std::wstring FormatCount(unsigned long long value); + /** + * @brief 把时间间隔格式化为「Δt 语义」的复合单位文本(不含 "Δt:" 前缀)。 + * + * 规则(用户明确要求,与双光标差值的显示完全一致): + * - 顶级单位高于 s(即 h / min)时**一路整合到 s**,例如 3662s → "1h 1min 2s"; + * - 顶级单位为 s 及以下时,若不是该单位的整数,则**向下一级递进**, + * 例如 5.06s → "5s 60ms";整秒数不加下级单位,例如 5s → "5s"; + * - 顶级单位为 ps 时不再递进次级单位,例如 5.123ps → "5.123 ps"。 + * + * @param [IN] seconds 时间间隔(秒)。 + * @return 形如 "1h 1min 2s" / "5s 60ms" 的间隔文本。 + */ + std::wstring FormatDurationCompound(double seconds); + + /** + * @brief 把时间间隔格式化为「秒」数值文本(不含单位),用于内联编辑。 + * + * 编辑时的量级**始终是秒**:即使显示为 "1h 1min 2s",进入编辑后也是 3602。 + * + * @param [IN] seconds 时间间隔(秒)。 + * @return 形如 "3602" 的文本。 + */ + std::wstring FormatDurationSeconds(double seconds); + /** * @brief 计算“美观”的坐标轴刻度步长(1/2/5 乘以 10 的整数次幂)。 * @param [IN] range 坐标轴数值跨度,必须为正数。 diff --git a/Utilities/Src/DataModel.cpp b/Utilities/Src/DataModel.cpp index 07e9dac..d10a384 100644 --- a/Utilities/Src/DataModel.cpp +++ b/Utilities/Src/DataModel.cpp @@ -1586,8 +1586,9 @@ namespace jdi bool SampleDisplayedSignalAt(const DataFile& file, const SignalData& signal, const SignalStyle& style, double time, bool interpolate, double& value) { - // 未启用水平镜像时与 SampleSignalAt 完全等价(避免两套实现产生细微差异)。 - if (!style.mirrorHorizontal) + // 未启用水平镜像 / 时间偏移时与 SampleSignalAt 完全等价 + //(避免两套实现产生细微差异)。 + if (!style.mirrorHorizontal && style.offsetTime == 0.0) { return SampleSignalAt(file, signal, time, interpolate, value); } @@ -1597,7 +1598,9 @@ namespace jdi { return false; } - if (time < file.minTime || time > file.maxTime) + // 绘制位置 = 源时刻 + offsetTime,因此查询源数据要用「绘制时刻 − offsetTime」。 + const double query = time - style.offsetTime; + if (query < file.minTime || query > file.maxTime) { return false; } @@ -1616,7 +1619,7 @@ namespace jdi if (!file.timeMonotonic) { - const int nearest = FindNearestSample(file, time); + const int nearest = FindNearestSample(file, query); if (nearest < 0 || static_cast(nearest) >= count) { return false; @@ -1626,7 +1629,7 @@ namespace jdi } const std::vector::const_iterator lower = - std::lower_bound(file.time.begin(), file.time.end(), time); + std::lower_bound(file.time.begin(), file.time.end(), query); const std::size_t rightIndex = static_cast(lower - file.time.begin()); if (rightIndex == 0) { @@ -1647,13 +1650,13 @@ namespace jdi if (!interpolate || !std::isfinite(v0) || !std::isfinite(v1) || !(t1 > t0)) { - const double leftDistance = std::fabs(time - t0); - const double rightDistance = std::fabs(t1 - time); + const double leftDistance = std::fabs(query - t0); + const double rightDistance = std::fabs(t1 - query); value = (leftDistance <= rightDistance) ? v0 : v1; return std::isfinite(value); } - const double ratio = (time - t0) / (t1 - t0); + const double ratio = (query - t0) / (t1 - t0); value = v0 + (v1 - v0) * ratio; return true; } diff --git a/Utilities/Src/PopupMenu.cpp b/Utilities/Src/PopupMenu.cpp index 34fe599..da56bc9 100644 --- a/Utilities/Src/PopupMenu.cpp +++ b/Utilities/Src/PopupMenu.cpp @@ -215,10 +215,13 @@ namespace jdi float PopupMenu::NaturalEditFieldWidth(const MenuItem& item) const { + const std::wstring& widest = + (item.editValue.size() > item.value.size()) ? item.editValue : item.value; const float natural = std::max(kEditFieldWidth * m_scale, - UiMeasureTextWidth(m_fonts->uiSmall, item.value) + 20.0f * m_scale); + UiMeasureTextWidth(m_fonts->uiSmall, widest) + 20.0f * m_scale); + const float withSuffix = natural + EditSuffixWidth(item); // 无说明文字 = 整行输入框(如「系数调整」面板):给一个更宽的默认值,视觉上独占整行。 - return item.label.empty() ? std::max(natural, kRowFieldWidth * m_scale) : natural; + return item.label.empty() ? std::max(withSuffix, kRowFieldWidth * m_scale) : withSuffix; } void PopupMenu::CloseSubmenu() @@ -346,6 +349,15 @@ namespace jdi return -1; } + float PopupMenu::EditSuffixWidth(const MenuItem& item) const + { + if (item.kind != MenuItemKind::EDIT || item.editSuffix.empty()) + { + return 0.0f; + } + return UiMeasureTextWidth(m_fonts->uiSmall, item.editSuffix) + 8.0f * m_scale; + } + gd::RectF PopupMenu::EditFieldRect(int index) const { if (index < 0 || index >= static_cast(m_items.size())) @@ -420,8 +432,8 @@ namespace jdi return false; } - // 输入框初值取当前显示值,整段选中,便于直接键入新数值。 - m_edit.Begin(item.value, true, item.filter, 0u); + // 输入框初值取「编辑初值」(未设置时用显示值),整段选中,便于直接键入新数值。 + m_edit.Begin(item.editValue.empty() ? item.value : item.editValue, true, item.filter, 0u); m_editIndex = index; m_hoverIndex = index; return true; @@ -445,8 +457,10 @@ namespace jdi } MenuItem& item = m_items[static_cast(index)]; - // 立即回写显示值,使菜单上的数值与数据层一致(宿主会在派发后再纠正为夹取后的真实值)。 + // 立即回写显示值,使菜单上的数值与数据层一致(宿主会在派发后再纠正为夹取后的真实值); + // 键入文本同时记为**编辑初值**(`value` 与 `editValue` 不一致的行也能正确回显上次输入)。 item.value = text; + item.editValue = text; result.valid = true; result.scope = m_scope; @@ -471,17 +485,23 @@ namespace jdi } } - bool PopupMenu::UpdateItemValue(int id, const std::wstring& value) + bool PopupMenu::UpdateItemValue(int id, const std::wstring& value, const std::wstring& editValue) { for (MenuItem& item : m_items) { if (item.id == id && item.kind == MenuItemKind::EDIT) { - if (item.value == value) + const bool valueSame = (item.value == value); + const bool editSame = editValue.empty() || (item.editValue == editValue); + if (valueSame && editSame) { return true; } item.value = value; + if (!editValue.empty()) + { + item.editValue = editValue; + } RebuildLayout(); return true; } @@ -1212,9 +1232,31 @@ namespace jdi UiText(g, UiEllipsize(item.label, m_fonts->uiSmall, labelWidth), m_fonts->uiSmall, gd::RectF(labelLeft, rect.Y, labelWidth, rect.Height), labelColor, TextAlign::LEFT); + const float suffix = EditSuffixWidth(item); if (static_cast(index) == m_editIndex) { - UiDrawEditField(g, fieldRect, m_edit, m_fonts->uiSmall, m_scale); + // 编辑态:输入框底板覆盖整行,正文区向右让出后缀宽度, + // 后缀(单位提示)靠右对齐画在它右侧。 + // ⚠️ 必须用 `UiDrawEditFieldContent`(只画内容)——`UiDrawEditField` + // 会把底板/描边按传入矩形绘制,那样带后缀时外框会短一截。 + const gd::RectF textRect = + (suffix > 0.0f) + ? gd::RectF(fieldRect.X, fieldRect.Y, std::max(12.0f, fieldRect.Width - suffix), + fieldRect.Height) + : fieldRect; + UiFillRoundRect(g, fieldRect, 3.0f * m_scale, m_theme->cardBg); + UiDrawRoundRect(g, fieldRect, 3.0f * m_scale, m_theme->accent, 1.6f); + UiDrawEditFieldContent(g, textRect, m_edit, m_fonts->uiSmall, m_scale); + if (suffix > 0.0f) + { + const float suffixLeft = textRect.X + textRect.Width; + UiText(g, item.editSuffix, m_fonts->uiSmall, + gd::RectF(suffixLeft, fieldRect.Y, + std::max(4.0f, fieldRect.X + fieldRect.Width - suffixLeft - + 4.0f * m_scale), + fieldRect.Height), + m_theme->textMuted, TextAlign::RIGHT); + } } else { diff --git a/Utilities/Src/Utf8Util.cpp b/Utilities/Src/Utf8Util.cpp index b5bb3f9..b766fd9 100644 --- a/Utilities/Src/Utf8Util.cpp +++ b/Utilities/Src/Utf8Util.cpp @@ -24,6 +24,13 @@ namespace jdi { return c >= L'A' && c <= L'Z'; } + + /** @brief 时间间隔的显示单位。 */ + struct DurationUnit + { + double scale = 1.0; /**< 1 个该单位等于多少秒。 */ + const wchar_t* name = L"s"; /**< 单位名称。 */ + }; } std::wstring Utf8ToWide(const char* text, int length) @@ -339,9 +346,133 @@ namespace jdi return std::wstring(buffer); } - std::wstring FormatByteSize(unsigned long long bytes) + std::wstring FormatDurationCompound(double seconds) { - // 单位自适应:GB / MB / KB(阈值按 1024 进制)。 + if (!std::isfinite(seconds)) + { + return L"—"; + } + const double value = std::fabs(seconds); + if (value <= 0.0) + { + return L"0s"; + } + + // ---- 顶级单位高于 s(h / min):整合为 h/min/s(整数秒)---- + // 注意条件必须是 >= 60s:1~60s 属于"顶级单位为 s"的情况,需按下一条规则处理 + // (非整数秒要递进到 ms,例如 5.06s → "5s 60ms")。 + if (value >= 60.0) + { + const long long totalSeconds = static_cast(std::llround(value)); + long long hours = 0; + long long minutes = 0; + long long secs = totalSeconds; + if (totalSeconds >= 3600) + { + hours = totalSeconds / 3600; + secs = totalSeconds % 3600; + } + if (secs >= 60) + { + minutes = secs / 60; + secs = secs % 60; + } + + std::wstring text; + const auto append = [&text](long long amount, const wchar_t* unit) + { + if (amount <= 0) + { + return; + } + if (!text.empty()) + { + text += L" "; + } + text += std::to_wstring(amount); + text += unit; + }; + append(hours, L"h"); + append(minutes, L"min"); + append(secs, L"s"); + if (!text.empty()) + { + return text; + } + } + + // ---- 顶级单位为 s:整数秒 + 可选 ms ---- + if (value >= 1.0) + { + const double whole = std::floor(value); + const double fraction = value - whole; + std::wstring text = FormatValue(whole) + L"s"; + const double remainderMs = fraction * 1000.0; + if (remainderMs >= 1.0e-6) + { + text += L" "; + text += FormatValue(remainderMs); + text += L"ms"; + } + return text; + } + + // ---- 顶级单位低于 s:ms / us / ns / ps ---- + const DurationUnit subUnits[] = { + {1.0e-3, L"ms"}, {1.0e-6, L"us"}, {1.0e-9, L"ns"}, {1.0e-12, L"ps"}, + }; + const std::size_t subCount = sizeof(subUnits) / sizeof(subUnits[0]); + std::size_t index = subCount - 1; + for (std::size_t i = 0; i < subCount; ++i) + { + if (value >= subUnits[i].scale) + { + index = i; + break; + } + } + + // 顶级单位为 ps:不再递进次级单位(保留小数)。 + if (index + 1 >= subCount) + { + return FormatValue(value / subUnits[index].scale) + L" " + subUnits[index].name; + } + + const double scaled = value / subUnits[index].scale; + double whole = std::floor(scaled); + double fraction = scaled - whole; + // 浮点误差可能导致 fraction 极接近 1(例如 4.9999999),归一化一下。 + if (fraction >= 1.0 - 1.0e-9) + { + whole += 1.0; + fraction = 0.0; + } + + std::wstring text = FormatValue(whole); + text += subUnits[index].name; + // 仍是该单位的整数 → 不显示下级单位。 + const double remainder = fraction * subUnits[index].scale; + const double inNext = remainder / subUnits[index + 1].scale; + if (inNext >= 1.0e-6) + { + text += L" "; + text += FormatValue(inNext); + text += subUnits[index + 1].name; + } + return text; + } + + std::wstring FormatDurationSeconds(double seconds) + { + if (!std::isfinite(seconds)) + { + return L"0"; + } + return FormatValue(std::fabs(seconds)); + } + + std::wstring FormatByteSize(unsigned long long bytes) + { // 单位自适应:GB / MB / KB(阈值按 1024 进制)。 constexpr double kKilo = 1024.0; constexpr double kMega = kKilo * 1024.0; constexpr double kGiga = kMega * 1024.0;