diff --git a/Components/Application/Inc/Application.hpp b/Components/Application/Inc/Application.hpp index f56b00b..2c15e92 100644 --- a/Components/Application/Inc/Application.hpp +++ b/Components/Application/Inc/Application.hpp @@ -323,6 +323,20 @@ namespace jdi */ bool BuildDerivedSignal(const DerivedSignalRequest& request, bool makeVisible); + /** + * @brief 把一条信号按固定间隔重采样,并插入为一个新信号(带独立的新时间轴)。 + * + * 采样值先按该信号的「公式映射 + 系数 / 绝对值 / 垂直镜像 / Y 偏移」变换(与绘制一致, + * 时间轴偏移除外),再在 `[tMin, tMax]` 上按 `interval` 等间隔线性插值。 + * 新信号名 = `<原信号> (重采样 <自适应单位的频率>)`,插入到源信号下方。 + * + * @param [IN] groupIndex 源信号所属分组下标。 + * @param [IN] signalIndex 源信号下标。 + * @param [IN] interval 重采样间隔(秒,> 0)。 + * @return 构建并插入成功返回 true。 + */ + bool BuildResampledSignal(int groupIndex, int signalIndex, double interval); + /** * @brief 把一条信号插入到「源信号」的下方,并同步各标签页的引用与可见性。 * diff --git a/Components/Application/Src/ApplicationCommands.cpp b/Components/Application/Src/ApplicationCommands.cpp index ef4d51a..ab77019 100644 --- a/Components/Application/Src/ApplicationCommands.cpp +++ b/Components/Application/Src/ApplicationCommands.cpp @@ -775,6 +775,173 @@ namespace jdi return true; } + bool Application::BuildResampledSignal(int groupIndex, int signalIndex, double interval) + { + const SignalGroup* group = m_dataSet.GroupAt(groupIndex); + if (group == nullptr || signalIndex < 0 || + signalIndex >= static_cast(group->signals.size())) + { + return false; + } + if (!std::isfinite(interval) || interval <= 0.0) + { + return false; + } + + const SignalData& source = group->signals[static_cast(signalIndex)]; + const TimeAxis& axis = group->AxisOf(source); + const std::wstring baseName = source.name; + + // 0. 快照源信号的「调整」变换(插入会移动信号存储,之后取 `source` 会悬垂)。 + SignalStyle sourceStyle; + bool hasSourceStyle = false; + { + const SignalStyle* style = m_dataSet.Style(SignalRef{groupIndex, signalIndex}); + if (style != nullptr) + { + sourceStyle = *style; + hasSourceStyle = true; + } + } + + // 1. 收集「变换后」的采样点(升序)。变换与绘制一致: + // 公式映射 → 系数 → 绝对值 → 垂直镜像 → Y 偏移(时间轴偏移不烘值)。 + const std::size_t count = std::min(axis.time.size(), source.values.size()); + std::vector sourceTimes; + std::vector sourceValues; + sourceTimes.reserve(count); + sourceValues.reserve(count); + bool monotonic = true; + double previous = 0.0; + for (std::size_t index = 0; index < count; ++index) + { + const double time = axis.time[index]; + const double value = source.values[index]; + if (!std::isfinite(time) || !std::isfinite(value)) + { + continue; + } + if (!sourceTimes.empty()) + { + if (time < previous) + { + monotonic = false; + break; + } + if (time == previous) + { + continue; // 重复时刻只保留第一个 + } + } + previous = time; + sourceTimes.push_back(time); + sourceValues.push_back(hasSourceStyle ? ApplyDrawTransform(sourceStyle, value) : value); + } + if (!monotonic || sourceTimes.size() < 2) + { + m_plot.ShowPersistentHint(!monotonic ? L"重采样失败:该信号的时间轴不是单调递增的。" + : L"重采样失败:有效采样点不足两个。"); + return false; + } + + // 2. 生成目标时间刻度(等间隔)。 + const double t0 = sourceTimes.front(); + const double t1 = sourceTimes.back(); + const double span = t1 - t0; + if (!(span > 0.0)) + { + m_plot.ShowPersistentHint(L"重采样失败:该信号的时间跨度为 0。"); + return false; + } + // 采样点数上限:防止用户把间隔输得太小导致内存爆炸。 + constexpr double kMaxResamplePoints = 20000000.0; + const double stepsExact = std::floor(span / interval + 1.0e-9) + 1.0; + if (!(stepsExact >= 2.0)) + { + m_plot.ShowPersistentHint(L"重采样失败:重采样间隔大于信号时间跨度。"); + return false; + } + if (stepsExact > kMaxResamplePoints) + { + m_plot.ShowPersistentHint(L"重采样失败:目标采样点过多(超过 2000 万),请增大重采样间隔。"); + return false; + } + const std::size_t targetCount = static_cast(stepsExact); + + TimeAxis newAxis; + newAxis.name = axis.name; + newAxis.time.resize(targetCount); + newAxis.monotonic = true; + std::vector resampled(targetCount); + std::size_t cursor = 0; + const std::size_t last = sourceTimes.size() - 1; + for (std::size_t index = 0; index < targetCount; ++index) + { + const double time = std::min(t1, t0 + static_cast(index) * interval); + newAxis.time[index] = time; + // 单调推进游标(O(n) 总代价)。 + while (cursor < last && sourceTimes[cursor + 1] < time) + { + ++cursor; + } + if (cursor >= last) + { + resampled[index] = sourceValues[last]; + continue; + } + const double ta = sourceTimes[cursor]; + const double tb = sourceTimes[cursor + 1]; + if (time <= ta) + { + resampled[index] = sourceValues[cursor]; + continue; + } + if (tb <= ta) + { + resampled[index] = sourceValues[cursor]; + continue; + } + const double ratio = (time - ta) / (tb - ta); + const double a = sourceValues[cursor]; + const double b = sourceValues[cursor + 1]; + resampled[index] = a + (b - a) * ratio; + } + newAxis.minTime = t0; + newAxis.maxTime = newAxis.time.back(); + newAxis.finiteCount = targetCount; + + // 3. 追加新时间轴(重采样后的采样刻度无法复用源信号的时间轴)。 + std::size_t axisIndex = 0; + if (!m_dataSet.AppendGroupAxis(groupIndex, std::move(newAxis), axisIndex)) + { + return false; + } + + // 4. 组装新信号并插入到源信号下方。 + SignalData derived; + derived.name = baseName + L" (重采样 " + FormatFrequency(1.0 / interval) + L")"; + derived.axisIndex = axisIndex; + derived.values = std::move(resampled); + + int newIndex = -1; + if (!InsertSignalBelowSource(groupIndex, signalIndex, std::move(derived), true, newIndex)) + { + return false; + } + + // 5. 时间轴偏移**不烘值**:留在新信号的样式上(与其它派生信号同一规则)。 + if (hasSourceStyle && sourceStyle.offsetTime != 0.0) + { + if (SignalStyle* derivedStyle = m_dataSet.Style(SignalRef{groupIndex, newIndex})) + { + derivedStyle->offsetTime = sourceStyle.offsetTime; + } + } + + SyncTabsToDataSet(); + return true; + } + bool Application::InsertSignalBelowSource(int groupIndex, int sourceIndex, SignalData&& signal, bool makeVisible, int& newIndex) { @@ -902,6 +1069,43 @@ namespace jdi bool Application::ApplyPendingDerivedBuild() { + // 「重采样」的构建流程与均值/弱峰不同(要新建时间轴),单独处理。 + { + int laneIndex = -1; + std::vector items; + if (m_plot.TakePendingResampleBuild(laneIndex, items) && !items.empty()) + { + // **同一文件内必须按下标降序处理**(每次插入只让插入点之后的信号下标 +1)。 + std::sort(items.begin(), items.end(), + [](const PlotView::ResampleBuild& a, const PlotView::ResampleBuild& b) + { + if (a.groupIndex != b.groupIndex) + { + return a.groupIndex < b.groupIndex; + } + return a.signalIndex > b.signalIndex; + }); + int built = 0; + double lastInterval = 0.0; + for (const PlotView::ResampleBuild& item : items) + { + if (BuildResampledSignal(item.groupIndex, item.signalIndex, item.interval)) + { + ++built; + lastInterval = item.interval; + } + } + if (built > 0) + { + SetStatusMessage(L"已构建 " + std::to_wstring(built) + L" 条重采样信号(频率 " + + FormatFrequency(lastInterval > 0.0 ? 1.0 / lastInterval : 0.0) + + L")。", + false); + } + return built > 0; + } + } + // 两类「构建为新信号」请求(均值滤波 / 弱峰调整)机制完全一致, // 统一整理成同一份构建清单后按下标降序处理。 std::vector requests; @@ -1127,6 +1331,13 @@ namespace jdi freeMarker.label = L"自由标记"; freeMarker.id = PLOT_CMD_LANE_FREE_MARKER; freeMarker.checked = m_plot.LaneFreeMarker(laneIndex); + + // 「重采样」位于「自由标记」**上方**(用户 2026-11-02 要求)。 + MenuItem resample; + resample.label = L"重采样"; + resample.id = PLOT_CMD_LANE_RESAMPLE; + resample.checked = m_plot.LaneResample(laneIndex); + experimental.submenu.push_back(resample); experimental.submenu.push_back(freeMarker); } items.push_back(experimental); @@ -1355,6 +1566,15 @@ namespace jdi case MenuScope::SIGNAL_PANEL: { const bool needRelayout = m_panel.OnMenuCommand(result); + // 「公式映射」等就地输入可能产生**非模态**警告(不使用 Windows 消息框), + // 由绘图区底部的自绘提示浮层显示(与加载失败提示同一套)。 + { + std::wstring hint; + if (m_panel.TakePendingHint(hint)) + { + m_plot.ShowPersistentHint(hint); + } + } if (needRelayout) { Relayout(); @@ -1418,6 +1638,10 @@ namespace jdi // 「实验性功能 → 自由标记」:切换网格内跟随鼠标的纵向光标与固定数据点面板。 m_plot.ToggleLaneFreeMarker(laneIndex); break; + case PLOT_CMD_LANE_RESAMPLE: + // 「实验性功能 → 重采样」:切换重采样参数面板。 + m_plot.ToggleLaneResample(laneIndex); + break; case PLOT_CMD_LANE_SPLIT: // 「拆分当前网格」:信号按个拆成独立网格,新网格顺次插入其下方。 m_plot.SplitLane(laneIndex); diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 10df503..b4e43e7 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -65,7 +65,8 @@ namespace jdi MARKER_SCROLL, /**< 拖动「自由标记」面板内容的滚动条滑块。 */ MEAN_POINTS, /**< 拖动「均值滤波」某信号的「均值点数」滑块(0 … 该信号自定义上限)。 */ MEAN_ITERATIONS,/**< 拖动「均值滤波」某信号的「迭代次数」滑块(1 … 该信号自定义上限)。 */ - MEAN_SCROLL /**< 拖动「均值滤波」面板内容的滚动条滑块。 */ + MEAN_SCROLL,/**< 拖动「均值滤波」面板内容的滚动条滑块。 */ + RESAMPLE_SCROLL /**< 拖动「重采样」面板内容的滚动条滑块。 */ }; /** @@ -103,6 +104,8 @@ namespace jdi MEAN_POINTS_MAX, /** @brief 编辑「均值滤波」某信号「迭代次数」滑块的**上限**(正整数)。 */ MEAN_ITERATIONS_MAX, + RESAMPLE_INTERVAL, /**< 编辑「重采样」某信号的**重采样间隔**(秒)。 */ + RESAMPLE_FREQUENCY, /**< 编辑「重采样」某信号的**重采样频率**(Hz,与间隔联动)。 */ FREE_MARKER_TIME, /**< 编辑「自由标记」某固定点的**自身时间**(秒)。 */ FREE_MARKER_DELTA /**< 编辑「自由标记」某固定点相对前一固定点的**时间间隔**(秒)。 */ }; @@ -123,7 +126,8 @@ namespace jdi PLOT_CMD_LANE_SPLIT = 9, /**< 拆分当前网格:把网格内信号按个拆成独立网格并顺次下移编号。 */ PLOT_CMD_LANE_MEAN_FILTER = 10, /**< 切换本网格的「实验性功能 → 均值滤波」开关。 */ PLOT_CMD_LANE_WEAK_PEAK = 11, /**< 切换本网格的「实验性功能 → 弱峰调整」开关。 */ - PLOT_CMD_LANE_FREE_MARKER = 12 /**< 切换本网格的「实验性功能 → 自由标记」开关。 */ + PLOT_CMD_LANE_FREE_MARKER = 12, /**< 切换本网格的「实验性功能 → 自由标记」开关。 */ + PLOT_CMD_LANE_RESAMPLE = 13 /**< 切换本网格的「实验性功能 → 重采样」开关。 */ }; /** @@ -135,7 +139,8 @@ namespace jdi CENTER = 1, /**< 边沿计数(中心轴)。 */ MEAN = 2, /**< 均值滤波。 */ PEAK = 3, /**< 弱峰调整。 */ - MARK = 4 /**< 自由标记。 */ + MARK = 4, /**< 自由标记。 */ + RESAMPLE = 5 /**< 重采样(把非固定频率采样规整为固定频率)。 */ }; /** @@ -1022,6 +1027,66 @@ namespace jdi */ bool ToggleLaneFreeMarker(int laneIndex); + /** + * @brief 「重采样 → 构建为新信号」的单个请求(一个被勾选的信号 + 自己的间隔)。 + */ + struct ResampleBuild + { + int groupIndex = -1; /**< 信号所属分组下标。 */ + int signalIndex = -1; /**< 组内信号下标。 */ + double interval = 0.0; /**< 重采样间隔(秒,> 0)。 */ + }; + + /** + * @brief 取出「重采样 → 构建为新信号」请求(由面板底部按钮触发)。 + * @param [OUT] laneIndex 触发该请求的网格下标。 + * @param [OUT] items 需要构建的信号及其间隔(按信号在网格内的顺序)。 + * @return 存在待处理请求返回 true(取出后清空)。 + */ + bool TakePendingResampleBuild(int& laneIndex, std::vector& items); + + /** + * @brief 开启 / 关闭某网格的「重采样」功能。 + * @param [IN] laneIndex 网格下标。 + * @param [IN] enabled 是否开启。 + * @return 状态发生变化返回 true。 + */ + bool SetLaneResample(int laneIndex, bool enabled); + + /** @brief 查询某网格的「重采样」开关。 */ + bool LaneResample(int laneIndex) const; + + /** + * @brief 切换某网格的「重采样」开关。 + * @param [IN] laneIndex 网格下标。 + * @return 切换后的状态。 + */ + bool ToggleLaneResample(int laneIndex); + + /** @brief 切换某信号是否参与重采样(面板条目栏右侧复选框)。 */ + bool ToggleLaneResampleSignal(int laneIndex, const SignalRef& ref); + + /** @brief 查询某信号是否参与重采样。 */ + bool LaneResampleSignalEnabled(int laneIndex, const SignalRef& ref) const; + + /** + * @brief 设置「重采样」面板内容的滚动偏移(自动夹到合法范围)。 + * @param [IN] laneIndex 网格下标。 @param [IN] scroll 目标偏移(像素)。 + */ + bool SetLaneResampleScroll(int laneIndex, float scroll); + + /** + * @brief 查询某信号的最大 / 最小数据间隔(相邻有效采样点的时间差极值)。 + * + * 只统计**时间轴与采样值都有效**的相邻采样对;不足两个有效采样时返回 false。 + * + * @param [IN] ref 目标信号。 + * @param [OUT] maxInterval 最大间隔(秒)。 + * @param [OUT] minInterval 最小间隔(秒)。 + * @return 至少存在一个有效间隔返回 true。 + */ + bool SignalDataInterval(const SignalRef& ref, double& maxInterval, double& minInterval) const; + /** * @brief 设置某网格是否处于「自由标记点选取」(浮动面板底部的切换按钮)。 * @@ -1902,6 +1967,17 @@ namespace jdi */ std::vector rowLevelResets; std::vector rowIterResets; + /** + * @brief 「重采样」面板每个被勾选信号的四行内容(与 `rowRefs` 同序)。 + * + * `rowInfoMaxInterval` / `rowInfoMinInterval` = 只读统计(最大 / 最小数据间隔); + * `rowIntervalField` / `rowFrequencyField` = 两行**联动输入框**(间隔秒 / 频率 Hz)。 + * 未勾选或非 RESAMPLE 面板时对应元素为空矩形(数组也可能为空)。 + */ + std::vector rowInfoMaxInterval; + std::vector rowInfoMinInterval; + std::vector rowIntervalField; + std::vector rowFrequencyField; /** * @brief 「均值滤波」面板每个信号的**信号色叠底标题栏**矩形(与 `rowRefs` 同序)。 * @@ -2896,6 +2972,8 @@ namespace jdi int m_pendingWeakPeakLane = -1; /**< 待构建的弱峰调整信号所属网格下标(-1 表示无请求)。 */ std::vector m_pendingMeanFilterItems; /**< 待构建的信号及其参数。 */ std::vector m_pendingWeakPeakItems; /**< 待构建的弱峰调整信号及其参数。 */ + int m_pendingResampleLane = -1; /**< 待构建的重采样信号所属网格下标(-1 表示无请求)。 */ + std::vector m_pendingResampleItems; /**< 待构建的重采样信号及其间隔。 */ bool m_mouseOverPanel = false; /**< 上一帧鼠标是否落在某个实验性功能面板上。 */ int m_peakDragLane = -1; /**< 正在拖动弱峰滑块的面板所属网格。 */ SignalRef m_peakDragRef; /**< 正在拖动弱峰滑块的**信号**(每个信号一套参数)。 */ @@ -3585,6 +3663,7 @@ namespace jdi bool mirrorHorizontal = false; /**< 是否水平镜像(数据反序)。 */ float offsetY = 0.0f; /**< Y 轴偏移。 */ double offsetTime = 0.0; /**< 时间轴偏移(秒)。 */ + std::wstring formula; /**< 「公式映射」表达式(空 = 不映射)。 */ /** @brief 相等比较(用于判断"逐信号外观样式是否变化")。 */ bool operator==(const SceneStyleEntry& other) const @@ -3594,7 +3673,7 @@ namespace jdi gain == other.gain && absolute == other.absolute && mirrorVertical == other.mirrorVertical && mirrorHorizontal == other.mirrorHorizontal && offsetY == other.offsetY && - offsetTime == other.offsetTime; + offsetTime == other.offsetTime && formula == other.formula; } }; @@ -3729,6 +3808,7 @@ namespace jdi bool mirrorHorizontal = false; /**< 是否水平镜像。 */ float offsetY = 0.0f; /**< Y 偏移。 */ double offsetTime = 0.0; /**< 时间偏移。 */ + std::wstring formula; /**< 「公式映射」表达式(空 = 不映射)。 */ int originX = 0; /**< 位图左上角(屏幕 x)。 */ int originY = 0; /**< 位图左上角(屏幕 y)。 */ std::unique_ptr bitmap; /**< 32bpp ARGB 光晕图层(直通 alpha)。 */ diff --git a/Components/PlotView/Inc/PlotViewInternal.hpp b/Components/PlotView/Inc/PlotViewInternal.hpp index 87bdf34..c0be8e2 100644 --- a/Components/PlotView/Inc/PlotViewInternal.hpp +++ b/Components/PlotView/Inc/PlotViewInternal.hpp @@ -587,6 +587,26 @@ namespace jdi inline const wchar_t* const kWeakPeakTitle = L"弱峰调整"; /**< 弱峰调整面板标题。 */ inline const wchar_t* const kBuildSignalLabel = L"构建为新信号"; /**< 逐条目面板底部按钮文案(均值滤波 / 弱峰调整共用)。 */ + // ---- 「重采样」面板 ---- + /** 「重采样」面板标题。 */ + inline const wchar_t* const kResampleTitle = L"重采样"; + /** 只读统计行标签:最大数据间隔。 */ + inline const wchar_t* const kResampleMaxIntervalLabel = L"最大数据间隔"; + /** 只读统计行标签:最小数据间隔。 */ + inline const wchar_t* const kResampleMinIntervalLabel = L"最小数据间隔"; + /** 输入行标签:重采样间隔(秒)。 */ + inline const wchar_t* const kResampleIntervalLabel = L"重采样间隔"; + /** 输入行标签:重采样频率(Hz)。 */ + inline const wchar_t* const kResampleFrequencyLabel = L"重采样频率"; + /** + * @brief 重采样间隔的合法区间(秒)。 + * + * 下限取 1ns(再小已经没有实际意义的采样率,且会让新信号的采样数爆炸); + * 上限取 1e6 秒。 + */ + inline const double kResampleIntervalMin = 1.0e-9; + inline const double kResampleIntervalMax = 1.0e6; + // ---- 「均值滤波」面板的两行参数滑块(用户 2026-10-30 要求)---- /** * 标签一律**不带 `X` / `N`**:滑块的量程本身已经说明是数值,符号只会让标签变长。 @@ -1034,7 +1054,7 @@ namespace jdi */ inline const EdgePanelKind kEdgePanelKinds[] = {EdgePanelKind::COUNT, EdgePanelKind::CENTER, EdgePanelKind::MEAN, EdgePanelKind::PEAK, - EdgePanelKind::MARK}; + EdgePanelKind::MARK, EdgePanelKind::RESAMPLE}; /** 实验性功能浮动面板的种类数(遍历与命中测试统一用它,避免新增功能时漏改)。 */ inline const int kEdgePanelKindCount = static_cast(sizeof(kEdgePanelKinds) / sizeof(kEdgePanelKinds[0])); @@ -1274,6 +1294,36 @@ namespace jdi return FormatValue(seconds) + L"s"; } + /** + * @brief 时间间隔的显示文本(**不带** "Δt:" 前缀)。 + * + * 「重采样」面板的「最大 / 最小数据间隔」两行用它:单位随间隔长短自适应 + *(`FormatDurationCompound`:1h 1min 2s / 5s 60ms / 120us …)。 + * + * @param [IN] seconds 时间间隔(秒)。 + * @return 文本。 + */ + inline std::wstring FormatIntervalText(double seconds) + { + if (!std::isfinite(seconds) || seconds <= 0.0) + { + return L"—"; + } + return FormatDurationCompound(seconds); + } + + /** + * @brief 频率的显示文本(单位在 Hz / kHz / MHz / GHz 之间自适应,保留小数)。 + * + * @param [IN] hertz 频率(Hz)。 + * @return 形如 "1.2 kHz" 的文本;非法值返回 "—"。 + */ + inline std::wstring FormatFrequencyText(double hertz) + { + // 唯一实现在 `Utf8Util::FormatFrequency`(宿主构建派生信号名时也用它)。 + return FormatFrequency(hertz); + } + /** * @brief 按比例缩放颜色的 alpha 通道(用于提示浮层淡出)。 * @param [IN] argb 原始 0xAARRGGBB 颜色。 diff --git a/Components/PlotView/Src/PlotViewCacheKey.cpp b/Components/PlotView/Src/PlotViewCacheKey.cpp index 51a562f..886fa3f 100644 --- a/Components/PlotView/Src/PlotViewCacheKey.cpp +++ b/Components/PlotView/Src/PlotViewCacheKey.cpp @@ -208,6 +208,8 @@ namespace jdi // 偏移(Y 平移 + 时间平移)同样会改变波形。 mixFloat(resolved.style->offsetY); mixFloat(static_cast(resolved.style->offsetTime)); + // 「公式映射」逐点改变取值 ⇒ 必须进指纹(否则改公式后波形不变)。 + mixText(resolved.style->formula); } // 图例文案参与绘制,重命名必须让缓存失效。 mixText(resolved.data->name); @@ -263,6 +265,7 @@ namespace jdi entry.mirrorHorizontal = resolved.style->mirrorHorizontal; entry.offsetY = resolved.style->offsetY; entry.offsetTime = resolved.style->offsetTime; + entry.formula = resolved.style->formula; } lane.push_back(entry); } diff --git a/Components/PlotView/Src/PlotViewFeatures.cpp b/Components/PlotView/Src/PlotViewFeatures.cpp index e639399..e08d1b4 100644 --- a/Components/PlotView/Src/PlotViewFeatures.cpp +++ b/Components/PlotView/Src/PlotViewFeatures.cpp @@ -1811,8 +1811,187 @@ namespace jdi return true; } - bool PlotView::SetLaneEdgeCenterScroll(int laneIndex, float scroll) + // ============================ 「重采样」 ============================ + + bool PlotView::SignalDataInterval(const SignalRef& ref, double& maxInterval, + double& minInterval) const { + maxInterval = 0.0; + minInterval = 0.0; + if (m_dataSet == nullptr) + { + return false; + } + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || resolved.axis == nullptr) + { + return false; + } + const TimeAxis& axis = *resolved.axis; + const SignalData& signal = *resolved.data; + const std::size_t count = std::min(axis.time.size(), signal.values.size()); + bool havePrevious = false; + double previousTime = 0.0; + bool any = false; + for (std::size_t index = 0; index < count; ++index) + { + const double time = axis.time[index]; + if (!std::isfinite(time) || !std::isfinite(signal.values[index])) + { + continue; + } + if (havePrevious) + { + const double gap = time - previousTime; + if (gap > 0.0) + { + if (!any) + { + maxInterval = gap; + minInterval = gap; + any = true; + } + else + { + maxInterval = std::max(maxInterval, gap); + minInterval = std::min(minInterval, gap); + } + } + } + previousTime = time; + havePrevious = true; + } + return any; + } + + bool PlotView::SetLaneResample(int laneIndex, bool enabled) + { + if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) + { + return false; + } + Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; + if (lane.resample == enabled) + { + return false; + } + lane.resample = enabled; + if (enabled) + { + // 默认只勾选网格内**第一个可见信号**(与其它实验性功能一致)。 + if (lane.resampleItems.empty()) + { + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data != nullptr && resolved.visible) + { + Lane::ResampleItem& item = lane.EnsureResampleItem(ref); + double maxInterval = 0.0; + double minInterval = 0.0; + if (SignalDataInterval(ref, maxInterval, minInterval)) + { + item.maxInterval = maxInterval; + item.minInterval = minInterval; + item.interval = minInterval; + } + item.initialized = true; + break; + } + } + } + lane.resamplePanel.open = true; + } + else + { + lane.resamplePanel.open = false; + } + EnsureLaneLayersPrefetched(); + return true; + } + + bool PlotView::LaneResample(int laneIndex) const + { + if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) + { + return false; + } + return m_tab->Lanes()[static_cast(laneIndex)].resample; + } + + bool PlotView::ToggleLaneResample(int laneIndex) + { + const bool enabled = !LaneResample(laneIndex); + SetLaneResample(laneIndex, enabled); + return enabled; + } + + bool PlotView::ToggleLaneResampleSignal(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.ResampleEnabled(ref)) + { + lane.RemoveResampleItem(ref); + return false; + } + // 勾选时一次性算好最大 / 最小数据间隔(面板绘制只读缓存值)。 + Lane::ResampleItem& item = lane.EnsureResampleItem(ref); + double maxInterval = 0.0; + double minInterval = 0.0; + if (SignalDataInterval(ref, maxInterval, minInterval)) + { + item.maxInterval = maxInterval; + item.minInterval = minInterval; + item.interval = minInterval; + } + item.initialized = true; + return true; + } + + bool PlotView::LaneResampleSignalEnabled(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)].ResampleEnabled(ref); + } + + bool PlotView::SetLaneResampleScroll(int laneIndex, float scroll) + { + if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) + { + return false; + } + Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; + const float clamped = std::max(0.0f, scroll); + if (std::fabs(lane.resampleScroll - clamped) < 0.5f) + { + return false; + } + lane.resampleScroll = clamped; + return true; + } + + bool PlotView::TakePendingResampleBuild(int& laneIndex, std::vector& items) + { + if (m_pendingResampleLane < 0) + { + return false; + } + laneIndex = m_pendingResampleLane; + items = m_pendingResampleItems; + m_pendingResampleLane = -1; + m_pendingResampleItems.clear(); + return true; + } + + + bool PlotView::SetLaneEdgeCenterScroll(int laneIndex, float scroll) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) { return false; @@ -1916,6 +2095,9 @@ namespace jdi case EdgePanelKind::MARK: state = &lane.freeMarkerPanel; break; + case EdgePanelKind::RESAMPLE: + state = &lane.resamplePanel; + break; default: return false; } @@ -1946,6 +2128,8 @@ namespace jdi return lane.weakPeakPanel.collapsed; case EdgePanelKind::MARK: return lane.freeMarkerPanel.collapsed; + case EdgePanelKind::RESAMPLE: + return lane.resamplePanel.collapsed; default: return false; } @@ -3393,7 +3577,8 @@ namespace jdi const bool mirrored = (resolvedStyle != nullptr) && resolvedStyle->mirrorHorizontal; const bool transform = (resolvedStyle != nullptr) && (resolvedStyle->gain != 1.0f || resolvedStyle->absolute || - resolvedStyle->mirrorVertical || resolvedStyle->offsetY != 0.0f); + resolvedStyle->mirrorVertical || resolvedStyle->offsetY != 0.0f || + resolvedStyle->HasFormula()); // ⚠️ **整段自适应直接取信号已缓存的 min/max**(O(1)): // 当视界覆盖整条信号、且该信号没有任何数值变换时,逐点扫描的结果与 diff --git a/Components/PlotView/Src/PlotViewInteraction.cpp b/Components/PlotView/Src/PlotViewInteraction.cpp index 2d8fd76..f48b3a8 100644 --- a/Components/PlotView/Src/PlotViewInteraction.cpp +++ b/Components/PlotView/Src/PlotViewInteraction.cpp @@ -391,7 +391,9 @@ namespace jdi m_editTarget == PlotEditTarget::MEAN_FILTER_ITERATIONS || m_editTarget == PlotEditTarget::PEAK_ITERATIONS_MAX || m_editTarget == PlotEditTarget::MEAN_POINTS_MAX || - m_editTarget == PlotEditTarget::MEAN_ITERATIONS_MAX) + m_editTarget == PlotEditTarget::MEAN_ITERATIONS_MAX || + m_editTarget == PlotEditTarget::RESAMPLE_INTERVAL || + m_editTarget == PlotEditTarget::RESAMPLE_FREQUENCY) { // 「均值点数 X」/「迭代次数 N」(均值滤波、中心轴)与「滑块上限」(弱峰调整 / 均值滤波): // 输入框都落在对应的字段/滑块行矩形上。 @@ -613,6 +615,31 @@ namespace jdi return true; } + // 「重采样」两行输入框:间隔(秒)与频率(Hz)**联动**,改任意一个都同时更新两者。 + // ⚠️ 输入框内的数值单位:间隔 = 秒、频率 = Hz(显示态再按大小自适应单位)。 + if (target == PlotEditTarget::RESAMPLE_INTERVAL || + target == PlotEditTarget::RESAMPLE_FREQUENCY) + { + Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; + Lane::ResampleItem* existing = const_cast( + lane.FindResampleItem(m_editMeanRef)); + if (existing == nullptr) + { + return true; + } + // 频率 → 间隔(或反之),非法值(0 / 负数 / 非有限)不生效。 + const double interval = + (target == PlotEditTarget::RESAMPLE_FREQUENCY) ? ((parsed > 0.0) ? (1.0 / parsed) : -1.0) + : parsed; + if (interval > 0.0 && std::isfinite(interval)) + { + Lane::ResampleItem& item = lane.EnsureResampleItem(m_editMeanRef); + item.interval = std::max(kResampleIntervalMin, std::min(interval, kResampleIntervalMax)); + item.initialized = true; + } + return true; + } + // 「弱峰调整」迭代次数滑块的**上限**:用户可直接输入一个正整数作为新的量程上限。 // 规则(用户明确要求): // * 输入框只允许输入正整数(过滤策略为 BYTE = 纯数字,粘贴同样会被清洗); @@ -1011,7 +1038,8 @@ namespace jdi return nullptr; } if ((static_cast(panelKind) == EdgePanelKind::MEAN || - static_cast(panelKind) == EdgePanelKind::PEAK) && + static_cast(panelKind) == EdgePanelKind::PEAK || + static_cast(panelKind) == EdgePanelKind::RESAMPLE) && layout.button.Width > 0.0f && UiPointInRect(layout.button, static_cast(x), static_cast(y))) { @@ -1080,7 +1108,8 @@ namespace jdi const EdgePanelKind cursorKind = static_cast(panelKind); const bool listCursor = (cursorKind == EdgePanelKind::MEAN || cursorKind == EdgePanelKind::CENTER || - cursorKind == EdgePanelKind::PEAK); + cursorKind == EdgePanelKind::PEAK || + cursorKind == EdgePanelKind::RESAMPLE); const Lane& cursorLane = Tab().Lanes()[static_cast(panelLane)]; if (listCursor) @@ -1102,6 +1131,11 @@ namespace jdi checked = cursorLane.FindEdgeCenterItem( layout.rowRefs[rowIndex]) != nullptr; } + else if (cursorKind == EdgePanelKind::RESAMPLE) + { + checked = cursorLane.FindResampleItem( + layout.rowRefs[rowIndex]) != nullptr; + } else { checked = cursorLane.FindMeanFilterItem( @@ -1109,6 +1143,23 @@ namespace jdi } return checked ? LoadCursorW(nullptr, IDC_HAND) : nullptr; } + if (cursorKind == EdgePanelKind::RESAMPLE) + { + // 两行输入框是可点击控件(点击进入就地编辑)。 + if (rowIndex < layout.rowIntervalField.size() && + UiPointInRect(layout.rowIntervalField[rowIndex], static_cast(x), + static_cast(y))) + { + return LoadCursorW(nullptr, IDC_HAND); + } + if (rowIndex < layout.rowFrequencyField.size() && + UiPointInRect(layout.rowFrequencyField[rowIndex], static_cast(x), + static_cast(y))) + { + return LoadCursorW(nullptr, IDC_HAND); + } + continue; + } const bool hasParams = rowIndex < layout.rowLevelTracks.size() && layout.rowLevelTracks[rowIndex].Width > 0.0f; @@ -1772,6 +1823,9 @@ namespace jdi case EdgePanelKind::PEAK: scrolled = SetLaneWeakPeakScroll(m_markerScrollDragLane, target); break; + case EdgePanelKind::RESAMPLE: + scrolled = SetLaneResampleScroll(m_markerScrollDragLane, target); + break; default: scrolled = SetLaneMeanFilterScroll(m_markerScrollDragLane, target); break; @@ -1954,7 +2008,7 @@ namespace jdi static_cast(hoverPanelKind); if (overEdgePanel && m_tab != nullptr && panelLane >= 0 && (hoverKind == EdgePanelKind::MEAN || hoverKind == EdgePanelKind::PEAK || - hoverKind == EdgePanelKind::CENTER)) + hoverKind == EdgePanelKind::CENTER || hoverKind == EdgePanelKind::RESAMPLE)) { const EdgePanelLayout layout = EdgePanelLayoutFor(panelLane, hoverKind); if (UiPointInRect(layout.content, static_cast(x), static_cast(y))) @@ -2350,15 +2404,17 @@ namespace jdi // 「中心轴边沿计数」/「均值滤波」/「弱峰调整」面板:逐信号条目 // (前两者是「名称 + 复选框 + 两个参数输入框」,弱峰调整是「名称 + 复选框 + 两行滑块」)。 if ((kind == EdgePanelKind::MEAN || kind == EdgePanelKind::CENTER || - kind == EdgePanelKind::PEAK || kind == EdgePanelKind::COUNT) && + kind == EdgePanelKind::PEAK || kind == EdgePanelKind::COUNT || + kind == EdgePanelKind::RESAMPLE) && m_tab != nullptr) { const bool meanMode = (kind == EdgePanelKind::MEAN); const bool centerMode = (kind == EdgePanelKind::CENTER); const bool boostMode = (kind == EdgePanelKind::PEAK); - // 「均值滤波」「中心轴边沿计数」「弱峰调整」自 2026-11-02 起共用同一套 - //**可滚动列表**交互(信号色叠底条目栏 + 右侧复选框 + 展开后的两行参数滑块)。 - const bool listMode = (meanMode || centerMode || boostMode); + const bool resampleMode = (kind == EdgePanelKind::RESAMPLE); + // 「均值滤波」「中心轴边沿计数」「弱峰调整」「重采样」自 2026-11-02 起共用同一套 + //**可滚动列表**交互(信号色叠底条目栏 + 右侧复选框 + 展开后的参数行)。 + const bool listMode = (meanMode || centerMode || boostMode || resampleMode); // 「边沿计数」= **表格**(表头 + 每信号一行),只有滚动条可拖。 if (kind == EdgePanelKind::COUNT) { @@ -2402,6 +2458,120 @@ namespace jdi } const bool insideContent = UiPointInRect(layout.content, fx, fy); + // 「重采样」:四行内容 = 两行只读统计 + 两行**输入框**(间隔 / 频率)。 + // 条目栏 = 复选框切换 + 已勾选时整行收起/展开(与其它列表式面板一致)。 + if (resampleMode) + { + for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) + { + if (!insideContent) + { + break; + } + const SignalRef& ref = layout.rowRefs[rowIndex]; + const Lane::ResampleItem* item = meanLane.FindResampleItem(ref); + if (item == nullptr) + { + continue; + } + // 点击「重采样间隔」输入框 → 编辑秒数。 + if (rowIndex < layout.rowIntervalField.size() && + layout.rowIntervalField[rowIndex].Width > 0.0f && + UiPointInRect(layout.rowIntervalField[rowIndex], fx, fy)) + { + m_editMeanRef = ref; + m_editMeanKind = kind; + const gd::RectF& field = layout.rowIntervalField[rowIndex]; + BeginEdit(PlotEditTarget::RESAMPLE_INTERVAL, index, + FormatValue(item->interval), field); + m_edit.Begin(FormatValue(item->interval), true, EditFilter::NUMBER, + 0u); + m_dragMode = PlotDragMode::NONE; + return true; + } + // 点击「重采样频率」输入框 → 编辑频率(Hz,与间隔联动)。 + if (rowIndex < layout.rowFrequencyField.size() && + layout.rowFrequencyField[rowIndex].Width > 0.0f && + UiPointInRect(layout.rowFrequencyField[rowIndex], fx, fy)) + { + m_editMeanRef = ref; + m_editMeanKind = kind; + const gd::RectF& field = layout.rowFrequencyField[rowIndex]; + const double hz = (item->interval > 0.0) ? (1.0 / item->interval) : 0.0; + BeginEdit(PlotEditTarget::RESAMPLE_FREQUENCY, index, FormatValue(hz), + field); + m_edit.Begin(FormatValue(hz), true, EditFilter::NUMBER, 0u); + m_dragMode = PlotDragMode::NONE; + return true; + } + } + // 条目栏(信号色叠底):复选框切换;已勾选时整行切换参数行收起/展开。 + for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) + { + const SignalRef& ref = layout.rowRefs[rowIndex]; + if (!insideContent || rowIndex >= layout.rowHeaders.size() || + layout.rowHeaders[rowIndex].Width <= 0.0f || + !UiPointInRect(layout.rowHeaders[rowIndex], fx, fy)) + { + continue; + } + const bool onCheckBox = + rowIndex < layout.rowChecks.size() && + layout.rowChecks[rowIndex].Width > 0.0f && + UiPointInRect(layout.rowChecks[rowIndex], fx, fy); + const Lane::ResampleItem* existing = meanLane.FindResampleItem(ref); + if (onCheckBox) + { + ToggleLaneResampleSignal(index, ref); + m_meanRowHoverLane = -1; + m_meanRowHoverRef = SignalRef(); + } + else if (existing != nullptr) + { + Lane::ResampleItem& editable = + meanLane.EnsureResampleItem(ref); + editable.collapsed = !editable.collapsed; + } + m_dragMode = PlotDragMode::NONE; + return true; + } + // 底部「构建为新信号」按钮。 + if (layout.button.Width > 0.0f && UiPointInRect(layout.button, fx, fy)) + { + const Lane& lane = m_tab->Lanes()[static_cast(index)]; + m_pendingResampleItems.clear(); + for (const SignalRef& ref : lane.signals) + { + const ResolvedSignal resolved = ResolveSignal(ref); + if (resolved.data == nullptr || !resolved.visible) + { + continue; + } + const Lane::ResampleItem* item = lane.FindResampleItem(ref); + if (item == nullptr || !(item->interval > 0.0)) + { + continue; + } + ResampleBuild build; + build.groupIndex = ref.groupIndex; + build.signalIndex = ref.signalIndex; + build.interval = item->interval; + m_pendingResampleItems.push_back(build); + } + if (!m_pendingResampleItems.empty()) + { + const ResampleBuild& first = m_pendingResampleItems.front(); + SetHint(L"正在构建重采样信号(间隔 " + + FormatIntervalText(first.interval) + L",共 " + + std::to_wstring(m_pendingResampleItems.size()) + L" 条)…"); + m_pendingResampleLane = index; + } + m_dragMode = PlotDragMode::NONE; + return true; + } + m_dragMode = PlotDragMode::NONE; + return true; // 面板内的其它位置:消费点击,不穿透 + } // 「弱峰调整」:两行滑块 = 调整幅度(-400% … +400%)/ 迭代次数(1 … 自定上限)。 // 语义与该面板旧式排版完全一致,只是外观改成与均值滤波同款的可滚动列表。 if (boostMode) @@ -3270,7 +3440,8 @@ namespace jdi // 「均值滤波 / 中心轴边沿计数 / 弱峰调整 / 边沿计数」面板:内容区超高时用滚轮翻页 //(上百个信号合并时必备)。 if (hoveredKind == EdgePanelKind::MEAN || hoveredKind == EdgePanelKind::CENTER || - hoveredKind == EdgePanelKind::PEAK || hoveredKind == EdgePanelKind::COUNT) + hoveredKind == EdgePanelKind::PEAK || hoveredKind == EdgePanelKind::COUNT || + hoveredKind == EdgePanelKind::RESAMPLE) { const EdgePanelLayout layout = EdgePanelLayoutFor(panelLane, hoveredKind); if (layout.content.Width > 0.0f && @@ -3289,6 +3460,8 @@ namespace jdi ? wheelLane.weakPeakScroll : (hoveredKind == EdgePanelKind::COUNT) ? wheelLane.edgeCountScroll + : (hoveredKind == EdgePanelKind::RESAMPLE) + ? wheelLane.resampleScroll : wheelLane.meanFilterScroll; const float next = current - static_cast(notches * 40.0 * m_scale); const float target = std::max(0.0f, std::min(next, maxScroll)); @@ -3303,6 +3476,9 @@ namespace jdi case EdgePanelKind::COUNT: SetLaneEdgeCountScroll(panelLane, target); break; + case EdgePanelKind::RESAMPLE: + SetLaneResampleScroll(panelLane, target); + break; default: SetLaneMeanFilterScroll(panelLane, target); break; diff --git a/Components/PlotView/Src/PlotViewPaint.cpp b/Components/PlotView/Src/PlotViewPaint.cpp index c3d1f48..55e9e17 100644 --- a/Components/PlotView/Src/PlotViewPaint.cpp +++ b/Components/PlotView/Src/PlotViewPaint.cpp @@ -150,6 +150,19 @@ namespace jdi mixHashFloat(offsetY); mixHashDouble(offsetTime); mixHashFloat(lineWidth); + // 「公式映射」会逐点改变取值 ⇒ 必须进折线缓存键(否则改公式后仍贴旧顶点)。 + { + std::uint64_t textHash = 1469598103934665603ull; + if (resolved.style != nullptr) + { + for (const wchar_t character : resolved.style->formula) + { + textHash ^= static_cast(static_cast(character)); + textHash *= 1099511628211ull; + } + } + mixHash(textHash); + } for (const PolylineCacheEntry& entry : m_polylineCache) { @@ -4071,6 +4084,16 @@ namespace jdi mix(static_cast(peakItem != nullptr ? peakItem->maxIterations + 2 : 0)); // 「弱峰调整」的条目栏三角(展开 / 收起)直接改变面板像素 ⇒ 必须进键。 mix((peakItem != nullptr && peakItem->collapsed) ? 1ull : 0ull); + // 「重采样」:勾选状态、两个输入框的数值(间隔 / 频率)与收起状态都直接显示在面板上。 + const Lane::ResampleItem* resampleItem = lane.FindResampleItem(ref); + mix(resampleItem != nullptr ? 1ull : 0ull); + if (resampleItem != nullptr) + { + std::uint64_t intervalBits = 0; + std::memcpy(&intervalBits, &resampleItem->interval, sizeof(intervalBits)); + mix(intervalBits); + mix(resampleItem->collapsed ? 1ull : 0ull); + } } // ⚠️⚠️ **边沿统计的数字本身就是面板内容,数字变化必须能让面板重绘**: @@ -4124,6 +4147,22 @@ namespace jdi { mixRect(rect); } + for (const gd::RectF& rect : layout.rowInfoMaxInterval) + { + mixRect(rect); + } + for (const gd::RectF& rect : layout.rowInfoMinInterval) + { + mixRect(rect); + } + for (const gd::RectF& rect : layout.rowIntervalField) + { + mixRect(rect); + } + for (const gd::RectF& rect : layout.rowFrequencyField) + { + mixRect(rect); + } mixFloat(layout.scrollOffset); // 鼠标落在面板内时,✕ / 复选框 / 滑块 / 底部按钮的悬停态都依赖精确坐标。 @@ -4742,12 +4781,14 @@ namespace jdi const bool meanFilter = (kind == EdgePanelKind::MEAN); const bool weakPeak = (kind == EdgePanelKind::PEAK); const bool freeMarker = (kind == EdgePanelKind::MARK); - // ⚠️ 「均值滤波」「中心轴边沿计数」「弱峰调整」= 可滚动的分组列表(条目栏 + 展开后的参数行); + const bool resamplePanel = (kind == EdgePanelKind::RESAMPLE); + // ⚠️ 「均值滤波」「中心轴边沿计数」「弱峰调整」「重采样」= 可滚动的分组列表 + //(条目栏 + 展开后的参数行); // **「边沿计数」= 表格**(表头:信号名 / 上升沿计数 / 下降沿计数),单独一支。 // 它们都**不能**再走旧的逐条目分支:那个分支没有任何裁切,滚动时会画到标题栏上。 - const bool listStyle = (meanFilter || centerAxis || weakPeak); + const bool listStyle = (meanFilter || centerAxis || weakPeak || resamplePanel); const bool countTable = (kind == EdgePanelKind::COUNT); - const bool hasButton = (meanFilter || weakPeak); + const bool hasButton = (meanFilter || weakPeak || resamplePanel); { if (index >= count) { @@ -4944,8 +4985,10 @@ namespace jdi meanFilter ? std::wstring(L"均值滤波") : (weakPeak ? std::wstring(kWeakPeakTitle) : (freeMarker ? std::wstring(L"自由标记") - : ((centerAxis ? L"中心轴边沿计数" : L"边沿计数") + - EdgeRangeSuffix()))); + : (resamplePanel + ? std::wstring(kResampleTitle) + : ((centerAxis ? L"中心轴边沿计数" : L"边沿计数") + + EdgeRangeSuffix())))); UiText(g, title, m_fonts->uiSmall, gd::RectF(badgeRect.X + badgeRect.Width + 8.0f * m_scale, panel.Y, panel.Width - badgeRect.Width - 8.0f * m_scale - titleHeight, @@ -5170,6 +5213,22 @@ namespace jdi { extendRow(layout.rowStats[rowIndex]); } + if (rowIndex < layout.rowInfoMaxInterval.size()) + { + extendRow(layout.rowInfoMaxInterval[rowIndex]); + } + if (rowIndex < layout.rowInfoMinInterval.size()) + { + extendRow(layout.rowInfoMinInterval[rowIndex]); + } + if (rowIndex < layout.rowIntervalField.size()) + { + extendRow(layout.rowIntervalField[rowIndex]); + } + if (rowIndex < layout.rowFrequencyField.size()) + { + extendRow(layout.rowFrequencyField[rowIndex]); + } if (rowBottom <= layout.content.Y || rowTop >= layout.content.Y + layout.content.Height) { @@ -5185,10 +5244,15 @@ namespace jdi weakPeak ? nullptr : (centerAxis ? tabLane.FindEdgeCenterItem(ref) : tabLane.FindMeanFilterItem(ref)); - const bool checked = weakPeak ? (peakItem != nullptr) : (item != nullptr); + const Lane::ResampleItem* resampleItem = + resamplePanel ? tabLane.FindResampleItem(ref) : nullptr; + const bool checked = resamplePanel ? (resampleItem != nullptr) + : (weakPeak ? (peakItem != nullptr) + : (item != nullptr)); const bool rowCollapsed = - weakPeak ? (peakItem != nullptr && peakItem->collapsed) - : (item != nullptr && item->collapsed); + resamplePanel ? (resampleItem != nullptr && resampleItem->collapsed) + : (weakPeak ? (peakItem != nullptr && peakItem->collapsed) + : (item != nullptr && item->collapsed)); // 内容区已由 `g.SetClip(layout.content)` 裁切(行可以“半截”显示,滚动更顺滑); // 已经完整滚出可视范围的行在上面就 `continue` 了,这里不再额外门控。 @@ -5232,6 +5296,99 @@ namespace jdi continue; } + // 「重采样」:四行 = 最大间隔(只读)/ 最小间隔(只读)/ + // 重采样间隔(输入)/ 重采样频率(输入)。标签在左、数值在右。 + if (resamplePanel) + { + const float labelWidth = + UiMeasureTextWidth(m_fonts->uiSmall, kResampleMaxIntervalLabel); + const auto paintInfoRow = [&](const gd::RectF& row, const wchar_t* label, + const std::wstring& text) + { + if (row.Width <= 0.0f) + { + return; + } + UiTextClipped(g, label, m_fonts->uiSmall, + gd::RectF(row.X, row.Y, std::max(10.0f, labelWidth), + row.Height), + m_theme->textMuted, TextAlign::LEFT, layout.content); + UiTextClipped(g, text, m_fonts->monoSmall, + gd::RectF(row.X + labelWidth + 8.0f * m_scale, row.Y, + std::max(10.0f, row.Width - labelWidth - + 8.0f * m_scale), + row.Height), + m_theme->textStrong, TextAlign::RIGHT, + layout.content); + }; + const auto paintInputRow = [&](const gd::RectF& field, const wchar_t* label, + const std::wstring& text, bool editing) + { + if (field.Width <= 0.0f) + { + return; + } + // 标签与数值同高:标签从内容区左缘画到字段左缘。 + UiTextClipped( + g, label, m_fonts->uiSmall, + gd::RectF(layout.content.X, field.Y, + std::max(10.0f, field.X - 8.0f * m_scale - layout.content.X), + field.Height), + m_theme->textMuted, TextAlign::LEFT, layout.content); + if (editing) + { + return; // 正在编辑:内容由 `PaintEditField` 画 + } + const gd::RectF box(field.X, field.Y + 2.0f * m_scale, field.Width, + std::max(10.0f, field.Height - 4.0f * m_scale)); + UiFillRoundRect(g, box, 3.0f * m_scale, m_theme->pressed); + UiDrawRoundRect(g, box, 3.0f * m_scale, m_theme->border, 1.0f); + UiTextClipped(g, text, m_fonts->monoSmall, + gd::RectF(box.X + 4.0f * m_scale, box.Y, + std::max(6.0f, box.Width - 8.0f * m_scale), + box.Height), + m_theme->textStrong, TextAlign::LEFT, layout.content); + }; + double maxInterval = 0.0; + double minInterval = 0.0; + const bool hasInterval = + (resampleItem->maxInterval > 0.0) && (resampleItem->minInterval > 0.0); + maxInterval = resampleItem->maxInterval; + minInterval = resampleItem->minInterval; + if (rowIndex < layout.rowInfoMaxInterval.size()) + { + paintInfoRow(layout.rowInfoMaxInterval[rowIndex], kResampleMaxIntervalLabel, + hasInterval ? FormatIntervalText(maxInterval) : L"—"); + } + if (rowIndex < layout.rowInfoMinInterval.size()) + { + paintInfoRow(layout.rowInfoMinInterval[rowIndex], kResampleMinIntervalLabel, + hasInterval ? FormatIntervalText(minInterval) : L"—"); + } + const double interval = (resampleItem->interval > 0.0) + ? resampleItem->interval + : minInterval; + const bool editingInterval = + (m_editTarget == PlotEditTarget::RESAMPLE_INTERVAL) && + (m_editLane == static_cast(index)) && (m_editMeanRef == ref); + const bool editingFrequency = + (m_editTarget == PlotEditTarget::RESAMPLE_FREQUENCY) && + (m_editLane == static_cast(index)) && (m_editMeanRef == ref); + if (rowIndex < layout.rowIntervalField.size()) + { + paintInputRow(layout.rowIntervalField[rowIndex], kResampleIntervalLabel, + FormatValue(interval), editingInterval); + } + if (rowIndex < layout.rowFrequencyField.size()) + { + const double hertz = + (interval > 0.0) ? (1.0 / interval) : 0.0; + paintInputRow(layout.rowFrequencyField[rowIndex], kResampleFrequencyLabel, + FormatFrequencyText(hertz), editingFrequency); + } + continue; + } + // 「中心轴边沿计数」:参数行最上面是**同一行**的「上升沿 / 下降沿」统计。 if (centerAxis && rowIndex < layout.rowStats.size() && layout.rowStats[rowIndex].Width > 0.0f) diff --git a/Components/PlotView/Src/PlotViewPanels.cpp b/Components/PlotView/Src/PlotViewPanels.cpp index 6cfb37f..09371de 100644 --- a/Components/PlotView/Src/PlotViewPanels.cpp +++ b/Components/PlotView/Src/PlotViewPanels.cpp @@ -28,12 +28,13 @@ namespace jdi const bool centerAxis = (kind == EdgePanelKind::CENTER); const bool meanFilter = (kind == EdgePanelKind::MEAN); const bool weakPeak = (kind == EdgePanelKind::PEAK); - // 「均值滤波」「中心轴边沿计数」「弱峰调整」= 可滚动的分组列表 + const bool resamplePanel = (kind == EdgePanelKind::RESAMPLE); + // 「均值滤波」「中心轴边沿计数」「弱峰调整」「重采样」= 可滚动的分组列表 //(信号色叠底条目栏 + 右侧复选框 + 展开后的参数行); // **「边沿计数」= 表格**(表头:信号名 / 上升沿计数 / 下降沿计数)。 // ⚠️ 旧式「逐条目」排版(无滚动的名称+参数行)已彻底删除:上百个信号时它会算出 // 上万像素高的面板,超出绘图区后**面板像素缓存一次都存不进去** ⇒ 每帧整块重画。 - const bool listStyle = (centerAxis || meanFilter || weakPeak); + const bool listStyle = (centerAxis || meanFilter || weakPeak || resamplePanel); const bool countTable = (kind == EdgePanelKind::COUNT); // 标题行 = “GX” 徽标 + 标题(“边沿计数” / “中心轴边沿计数” / “均值滤波” / “弱峰调整” @@ -41,7 +42,10 @@ namespace jdi const std::wstring title = meanFilter ? std::wstring(L"均值滤波") : (weakPeak ? std::wstring(kWeakPeakTitle) - : ((centerAxis ? L"中心轴边沿计数" : L"边沿计数") + EdgeRangeSuffix())); + : (resamplePanel + ? std::wstring(kResampleTitle) + : ((centerAxis ? L"中心轴边沿计数" : L"边沿计数") + + EdgeRangeSuffix()))); float width = kEdgePanelPad * 2.0f * m_scale + kLaneBadgeWidth * m_scale + 8.0f * m_scale + UiMeasureTextWidth(m_fonts->uiSmall, title) + kEdgePanelTitleHeight * m_scale; @@ -162,6 +166,22 @@ namespace jdi kWeakPeakResetGap * m_scale + kWeakPeakResetButtonSize * m_scale; width = std::max(width, kEdgePanelPad * 2.0f * m_scale + sliderRowWidth + (kFreeMarkerScrollWidth + 4.0f) * m_scale); + if (resamplePanel) + { + // 「重采样」四行 = 「标签列 + 数值列」(两行统计 + 两行输入框), + // 标签取四者中最宽的一个,数值列按间隔 / 频率的典型文本宽度取。 + const float resampleLabelColumn = + std::max(std::max(UiMeasureTextWidth(m_fonts->uiSmall, kResampleMaxIntervalLabel), + UiMeasureTextWidth(m_fonts->uiSmall, kResampleMinIntervalLabel)), + std::max(UiMeasureTextWidth(m_fonts->uiSmall, kResampleIntervalLabel), + UiMeasureTextWidth(m_fonts->uiSmall, kResampleFrequencyLabel))); + const float resampleValueColumn = + std::max(UiMeasureTextWidth(m_fonts->monoSmall, L"1.23456 GHz"), + UiMeasureTextWidth(m_fonts->monoSmall, L"1h 1min 2s")); + width = std::max(width, kEdgePanelPad * 2.0f * m_scale + resampleLabelColumn + + 8.0f * m_scale + resampleValueColumn + + (kFreeMarkerScrollWidth + 4.0f) * m_scale); + } } if (weakPeak) @@ -174,9 +194,9 @@ namespace jdi width = std::max(width, kEdgePanelPad * 2.0f * m_scale + rowWidth); } - if (meanFilter || weakPeak) + if (meanFilter || weakPeak || resamplePanel) { - // 底部按钮行 = 「构建为新信号」(仅均值滤波 / 弱峰调整有),按按钮文字实测宽度确定下限。 + // 底部按钮行 = 「构建为新信号」(均值滤波 / 弱峰调整 / 重采样有),按按钮文字实测宽度确定下限。 const float buttonWidth = UiMeasureTextWidth(m_fonts->uiSmall, kBuildSignalLabel) + 28.0f * m_scale; width = std::max(width, kEdgePanelPad * 2.0f * m_scale + buttonWidth); @@ -276,21 +296,26 @@ namespace jdi const bool meanFilter = (kind == EdgePanelKind::MEAN); const bool weakPeak = (kind == EdgePanelKind::PEAK); const bool freeMarker = (kind == EdgePanelKind::MARK); - // 「均值滤波」「中心轴边沿计数」「弱峰调整」共用同一套**可滚动分组列表**排布 + const bool resamplePanel = (kind == EdgePanelKind::RESAMPLE); + // 「均值滤波」「中心轴边沿计数」「弱峰调整」「重采样」共用同一套**可滚动分组列表**排布 //(信号色叠底条目栏 + 展开后的参数行);「边沿计数」自 2026-11-02 起走**表格**分支。 - const bool listStyle = (meanFilter || centerAxis || weakPeak); + const bool listStyle = (meanFilter || centerAxis || weakPeak || resamplePanel); const bool countTable = (kind == EdgePanelKind::COUNT); - const bool hasButton = (meanFilter || weakPeak); + const bool hasButton = (meanFilter || weakPeak || resamplePanel); const bool enabled = meanFilter ? lane.meanFilter : (weakPeak ? lane.weakPeak : (freeMarker ? lane.freeMarker - : (centerAxis ? lane.edgeCenter - : lane.edgeCount))); + : (resamplePanel ? lane.resample + : (centerAxis ? lane.edgeCenter + : lane.edgeCount)))); const bool open = meanFilter ? lane.meanFilterPanel.open : (weakPeak ? lane.weakPeakPanel.open : (freeMarker ? lane.freeMarkerPanel.open - : (centerAxis ? lane.edgeCenterPanel.open - : lane.edgePanel.open))); + : (resamplePanel + ? lane.resamplePanel.open + : (centerAxis + ? lane.edgeCenterPanel.open + : lane.edgePanel.open)))); if (!enabled || !open) { return layout; @@ -299,9 +324,11 @@ namespace jdi const bool collapsed = meanFilter ? lane.meanFilterPanel.collapsed : (weakPeak ? lane.weakPeakPanel.collapsed : (freeMarker ? lane.freeMarkerPanel.collapsed - : (centerAxis - ? lane.edgeCenterPanel.collapsed - : lane.edgePanel.collapsed))); + : (resamplePanel + ? lane.resamplePanel.collapsed + : (centerAxis + ? lane.edgeCenterPanel.collapsed + : lane.edgePanel.collapsed)))); layout.collapsed = collapsed; int rowCount = 0; @@ -368,7 +395,12 @@ namespace jdi } content += groupRow; bool expanded = false; - if (weakPeak) + if (resamplePanel) + { + const Lane::ResampleItem* item = lane.FindResampleItem(ref); + expanded = (item != nullptr && !item->collapsed); + } + else if (weakPeak) { const Lane::WeakPeakItem* item = lane.FindWeakPeakItem(ref); expanded = (item != nullptr && !item->collapsed); @@ -385,10 +417,18 @@ namespace jdi } if (expanded) { - content += sliderRow * 2.0f; - if (centerAxis) + if (resamplePanel) { - content += groupRow; // 统计行(上升沿 / 下降沿) + // 四行:最大间隔 / 最小间隔 / 重采样间隔 / 重采样频率。 + content += sliderRow * 4.0f; + } + else + { + content += sliderRow * 2.0f; + if (centerAxis) + { + content += groupRow; // 统计行(上升沿 / 下降沿) + } } } } @@ -489,13 +529,19 @@ namespace jdi const double storedX = meanFilter ? lane.meanFilterPanel.x : (weakPeak ? lane.weakPeakPanel.x : (freeMarker ? lane.freeMarkerPanel.x - : (centerAxis ? lane.edgeCenterPanel.x - : lane.edgePanel.x))); + : (resamplePanel + ? lane.resamplePanel.x + : (centerAxis + ? lane.edgeCenterPanel.x + : lane.edgePanel.x)))); const double storedY = meanFilter ? lane.meanFilterPanel.y : (weakPeak ? lane.weakPeakPanel.y : (freeMarker ? lane.freeMarkerPanel.y - : (centerAxis ? lane.edgeCenterPanel.y - : lane.edgePanel.y))); + : (resamplePanel + ? lane.resamplePanel.y + : (centerAxis + ? lane.edgeCenterPanel.y + : lane.edgePanel.y)))); int openPanelOrdinal = 0; { // ⚠️ 错开量用「**当前真正显示出来的面板**中排在本面板之前的个数」: @@ -519,12 +565,14 @@ namespace jdi return lane.weakPeak && lane.weakPeakPanel.open; case EdgePanelKind::MARK: return lane.freeMarker && lane.freeMarkerPanel.open; + case EdgePanelKind::RESAMPLE: + return lane.resample && lane.resamplePanel.open; } return false; }; - static const EdgePanelKind kOrder[5] = {EdgePanelKind::COUNT, EdgePanelKind::CENTER, - EdgePanelKind::MEAN, EdgePanelKind::PEAK, - EdgePanelKind::MARK}; + static const EdgePanelKind kOrder[6] = {EdgePanelKind::COUNT, EdgePanelKind::CENTER, + EdgePanelKind::MEAN, EdgePanelKind::PEAK, + EdgePanelKind::MARK, EdgePanelKind::RESAMPLE}; int openSeen = 0; bool found = false; for (std::size_t lane = 0; lane < m_lanes.size() && !found; ++lane) @@ -701,7 +749,9 @@ namespace jdi const bool scrollable = meanContentHeight > viewHeight + 0.5f; const float maxScroll = std::max(0.0f, meanContentHeight - viewHeight); const float storedScroll = meanFilter ? lane.meanFilterScroll - : (weakPeak ? lane.weakPeakScroll : lane.edgeCenterScroll); + : (weakPeak ? lane.weakPeakScroll + : (resamplePanel ? lane.resampleScroll + : lane.edgeCenterScroll)); const float scroll = std::max(0.0f, std::min(storedScroll, maxScroll)); const float innerWidth = std::max(0.0f, width - pad * 2.0f - (scrollable ? (scrollW + 4.0f * m_scale) : 0.0f)); @@ -734,6 +784,14 @@ namespace jdi const float resetGap = kWeakPeakResetGap * m_scale; const float trackHeight = kWeakPeakTrackHeight * m_scale; const float closeSize = kEdgePanelMeanCheckSize * m_scale; + // 「重采样」四行的标签列(「最大数据间隔」比均值/弱峰的标签更宽): + // 与 `EdgePanelWidth` 用同一组实测宽度,保证字段不会压到标签上。 + const float resampleLabelColumn = + std::max(std::max(UiMeasureTextWidth(m_fonts->uiSmall, kResampleMaxIntervalLabel), + UiMeasureTextWidth(m_fonts->uiSmall, kResampleMinIntervalLabel)), + std::max(UiMeasureTextWidth(m_fonts->uiSmall, kResampleIntervalLabel), + UiMeasureTextWidth(m_fonts->uiSmall, kResampleFrequencyLabel))); + const float rowLabelColumn = resamplePanel ? resampleLabelColumn : labelColumn; const float contentLeft = layout.content.X; const float trackLeft = contentLeft + labelColumn + 6.0f * m_scale; const float resetLeft = contentLeft + innerWidth - resetSize; @@ -751,7 +809,13 @@ namespace jdi } bool checked = false; bool itemCollapsed = false; - if (weakPeak) + if (resamplePanel) + { + const Lane::ResampleItem* item = lane.FindResampleItem(ref); + checked = (item != nullptr); + itemCollapsed = (item != nullptr && item->collapsed); + } + else if (weakPeak) { const Lane::WeakPeakItem* item = lane.FindWeakPeakItem(ref); checked = (item != nullptr); @@ -797,6 +861,42 @@ namespace jdi layout.rowIterTracks.push_back(gd::RectF()); layout.rowIterValues.push_back(gd::RectF()); layout.rowIterResets.push_back(gd::RectF()); + if (resamplePanel) + { + layout.rowInfoMaxInterval.push_back(gd::RectF()); + layout.rowInfoMinInterval.push_back(gd::RectF()); + layout.rowIntervalField.push_back(gd::RectF()); + layout.rowFrequencyField.push_back(gd::RectF()); + } + continue; + } + if (resamplePanel) + { + // 「重采样」四行:① 最大间隔(只读)② 最小间隔(只读) + // ③ 重采样间隔(输入)④ 重采样频率(输入,与③联动)。 + // 每行 = 左侧标签 + 右侧数值/输入框区域。 + layout.rowLevelTracks.push_back(gd::RectF()); + layout.rowLevelValues.push_back(gd::RectF()); + layout.rowLevelResets.push_back(gd::RectF()); + layout.rowIterTracks.push_back(gd::RectF()); + layout.rowIterValues.push_back(gd::RectF()); + layout.rowIterResets.push_back(gd::RectF()); + layout.rowInfoMaxInterval.push_back( + gd::RectF(contentLeft, rowY, innerWidth, sliderRow)); + rowY += sliderRow; + layout.rowInfoMinInterval.push_back( + gd::RectF(contentLeft, rowY, innerWidth, sliderRow)); + rowY += sliderRow; + // 输入行的**数值矩形**靠右对齐,宽度取剩余可用宽度(绘制框与编辑框同源)。 + const float fieldLeft = contentLeft + rowLabelColumn + 8.0f * m_scale; + const float fieldWidth = + std::max(24.0f * m_scale, innerWidth - rowLabelColumn - 8.0f * m_scale); + layout.rowIntervalField.push_back( + gd::RectF(fieldLeft, rowY, fieldWidth, sliderRow)); + rowY += sliderRow; + layout.rowFrequencyField.push_back( + gd::RectF(fieldLeft, rowY, fieldWidth, sliderRow)); + rowY += sliderRow; continue; } // 「中心轴边沿计数」:参数行最上面是**同一行**的「上升沿 / 下降沿」统计。 diff --git a/Components/PlotView/Src/PlotViewRaster.cpp b/Components/PlotView/Src/PlotViewRaster.cpp index c23285b..d13c7a3 100644 --- a/Components/PlotView/Src/PlotViewRaster.cpp +++ b/Components/PlotView/Src/PlotViewRaster.cpp @@ -142,7 +142,8 @@ namespace jdi cache.mirrorVertical == resolved.style->mirrorVertical && cache.mirrorHorizontal == resolved.style->mirrorHorizontal && cache.offsetY == resolved.style->offsetY && - cache.offsetTime == resolved.style->offsetTime; + cache.offsetTime == resolved.style->offsetTime && + cache.formula == resolved.style->formula; if (!sameKey) { @@ -185,6 +186,7 @@ namespace jdi cache.mirrorHorizontal = resolved.style->mirrorHorizontal; cache.offsetY = resolved.style->offsetY; cache.offsetTime = resolved.style->offsetTime; + cache.formula = resolved.style->formula; cache.originX = targetX; cache.originY = targetY; cache.valid = true; @@ -1185,6 +1187,16 @@ namespace jdi mix(resolved.style->mirrorHorizontal ? 1ull : 0ull); mixFloat(resolved.style->offsetY); mixFloat(static_cast(resolved.style->offsetTime)); + // 「公式映射」逐点改变图层里的波形 ⇒ 必须让图层失效。 + { + std::uint64_t textHash = 1469598103934665603ull; + for (const wchar_t character : resolved.style->formula) + { + textHash ^= static_cast(static_cast(character)); + textHash *= 1099511628211ull; + } + mix(textHash); + } } return hash; } diff --git a/Components/SignalPanel/Inc/SignalPanel.hpp b/Components/SignalPanel/Inc/SignalPanel.hpp index a632ac3..6ca2605 100644 --- a/Components/SignalPanel/Inc/SignalPanel.hpp +++ b/Components/SignalPanel/Inc/SignalPanel.hpp @@ -40,7 +40,9 @@ namespace jdi CMD_OFFSET_Y = 503, /**< 「偏移调整」:Y 轴偏移输入框。 */ CMD_OFFSET_TIME = 504, /**< 「偏移调整」:时间轴偏移输入框。 */ CMD_MIRROR_HORIZONTAL = 510, /**< 「镜像」二级面板:水平镜像(数据反序)。 */ - CMD_MIRROR_VERTICAL = 511 /**< 「镜像」二级面板:垂直镜像(按 Y=0 上下翻转)。 */ + CMD_MIRROR_VERTICAL = 511, /**< 「镜像」二级面板:垂直镜像(按 Y=0 上下翻转)。 */ + CMD_FORMULA_EDIT = 520, /**< 「公式映射」输入框(`Y = …`)。 */ + CMD_FORMULA_RESET = 521 /**< 「公式映射」标题栏右侧的还原按钮:恢复为 `f(t)`。 */ }; /** @@ -269,6 +271,27 @@ namespace jdi return laneIndex >= 0; } + /** + * @brief 取出并清除「需要以非模态浮层提示给用户」的消息。 + * + * 「公式映射」解析失败时用它把警告交给宿主:宿主调用 + * `PlotView::ShowPersistentHint()` 画在绘图区底部(与"加载文件失败"同一套自绘提示, + * **不弹 Windows 消息框**)。 + * + * @param [OUT] text 提示文本(取出后清空)。 + * @return 存在待提示的消息返回 true。 + */ + bool TakePendingHint(std::wstring& text) + { + if (m_pendingHint.empty()) + { + return false; + } + text = m_pendingHint; + m_pendingHint.clear(); + return true; + } + /** * @brief 向当前输入框送入一个字符。 * @param [IN] character 输入字符('\r' 提交、'\b' 退格、0x03 复制)。 @@ -1473,6 +1496,9 @@ namespace jdi bool m_plotLaneRequestFlag = false; int m_pendingPlotLane = -1; + /** 待宿主以非模态浮层提示的文本(见 `TakePendingHint`,例如公式解析失败)。 */ + std::wstring m_pendingHint; + /** * @brief 待确认的「关闭类」按钮(✕)。 * diff --git a/Utilities/Inc/DataModel.hpp b/Utilities/Inc/DataModel.hpp index a6f9b18..bf1447f 100644 --- a/Utilities/Inc/DataModel.hpp +++ b/Utilities/Inc/DataModel.hpp @@ -13,6 +13,7 @@ #include "DataLoader.hpp" #include "DataTable.hpp" +#include "FormulaEval.hpp" namespace jdi { @@ -230,6 +231,18 @@ namespace jdi float offsetY = 0.0f; /**< Y 轴偏移(叠加到绘制值上),默认 0。 */ double offsetTime = 0.0; /**< 时间轴偏移(秒,绘制在 t + offsetTime 处),默认 0。 */ + /** + * @brief 「公式映射」表达式(`Y = `,自变量写作 `f(t)`;空 = 不映射)。 + * + * 结算顺序为 **公式映射 → 系数 → 绝对值 → 垂直镜像 → Y 偏移 → 时间轴偏移**, + * 见 `ApplyDrawTransform`。表达式由 `Utilities/FormulaEval` 编译(带缓存), + * 非法表达式的取值原样返回采样值(不会让波形消失)。 + */ + std::wstring formula; + + /** @brief 是否启用了「公式映射」。 */ + bool HasFormula() const { return !formula.empty(); } + /** @brief 镜像功能的任一分量是否启用(决定「镜像」按钮的激活态)。 */ bool Mirrored() const { return mirrorVertical || mirrorHorizontal; } @@ -275,6 +288,12 @@ namespace jdi */ inline double ApplyDrawTransform(const SignalStyle& style, double value) { + // 「公式映射」最先结算(用户 2026-11-02 明确要求:公式 → 系数 → 偏移)。 + // ⚠️ 表达式编译结果按样式对象缓存,**不会**逐采样点重新解析。 + if (!style.formula.empty()) + { + value = EvaluateFormula(style.formula, value); + } if (style.gain != 1.0f) { value *= static_cast(style.gain); @@ -325,7 +344,9 @@ namespace jdi */ inline bool DrawTransformOrderPreserving(const SignalStyle& style) { - return (style.gain > 0.0f) && !style.absolute && !style.mirrorVertical; + // ⚠️ 「公式映射」不保证保序(`x * x` 在负半轴递减),因此一律按非保序处理, + // 让包络绘制回退到逐采样点变换(粗粒度分组层只存 min/max,非保序会算错)。 + return (style.gain > 0.0f) && !style.absolute && !style.mirrorVertical && !style.HasFormula(); } /** @@ -569,6 +590,19 @@ namespace jdi */ bool AddGroup(SignalGroup&& group, int& groupIndex); + /** + * @brief 在某个分组末尾追加一条**新时间轴**(「重采样」派生的信号使用它)。 + * + * 重采样产生的是**新的时间刻度**,无法复用源信号的时间轴;追加后该分组的 + * `axes` 多一条,返回它的下标,供随后 `InsertDerivedSignal` 的 `axisIndex` 使用。 + * + * @param [IN] groupIndex 目标分组下标。 + * @param [IN,OUT] axis 时间轴(内部缓冲会被移走)。 + * @param [OUT] axisIndex 新时间轴在分组内的下标。 + * @return 追加成功返回 true(轴为空/分组越界时返回 false)。 + */ + bool AppendGroupAxis(int groupIndex, TimeAxis&& axis, std::size_t& axisIndex); + /** * @brief 在指定分组的指定信号**下方**插入一条新信号(派生信号)。 * @@ -831,6 +865,33 @@ namespace jdi double time = 0.0; /**< 显示时刻(秒)。 */ }; + /** + * @brief 「重采样」中某个信号自己的参数。 + * + * 该功能把**非固定频率**采样的信号规整成固定间隔采样(超分 / 降采样), + * 再把结果派生为一条新信号。参数按信号保存,同一网格内不同信号可以用不同间隔。 + * + * ⚠️ `interval` 只在**首次勾选**(`initialized == false`)时初始化为该信号的最小 + * 数据间隔;之后用户手动输入的间隔不会被重新初始化覆盖。 + */ + struct ResampleItem + { + SignalRef ref; /**< 目标信号。 */ + double interval = 0.0; /**< 重采样间隔(秒,> 0)。 */ + /** @brief `interval` 是否已经按「该信号最小数据间隔」初始化过。 */ + bool initialized = false; + /** + * @brief 该信号的**最大 / 最小**数据间隔(相邻有效采样点时间差的极值)。 + * + * ⚠️ 在勾选该信号时由 `PlotView` 一次性算好并缓存(千万级信号逐帧重算会卡死), + * 面板绘制只读这两个字段。数据加载后不再变化,因此无需失效。 + */ + double maxInterval = 0.0; + double minInterval = 0.0; + /** @brief 参数行是否被**收起**(只保留信号色叠底的条目栏)。 */ + bool collapsed = false; + }; + std::wstring name; /**< 网格名称。 */ std::vector signals; /**< 该网格包含的信号(按加入顺序)。 */ bool autoRange = true; /**< 是否依据信号取值范围自动计算 Y 轴。 */ @@ -1102,6 +1163,42 @@ namespace jdi */ float edgeCountScroll = 0.0f; + /** + * @brief 「实验性功能 → 重采样」开关(功能总开关)。 + * + * 开启后本网格出现浮动面板:以网格内每个信号为条目(信号色叠底 + 复选框 + + * 展开/收起),展开后显示「最大数据间隔 / 最小数据间隔」两行统计与 + * 「重采样间隔 / 重采样频率」两行**联动输入框**;底部「构建为新信号」按当前参数 + * 把勾选的信号重采样成固定频率并插到源信号下方。 + * + * 与其它实验性功能一样属于**网格级**状态,重建网格时按信号继承(只由原网格首个信号持有)。 + */ + bool resample = false; + + /** @brief 参与重采样的信号及其参数(只包含被勾选的信号)。 */ + std::vector resampleItems; + + /** 取某个信号的重采样参数(未勾选时返回空指针)。 */ + const ResampleItem* FindResampleItem(const SignalRef& ref) const; + + /** 取得或创建某信号的重采样参数(用于勾选)。 */ + ResampleItem& EnsureResampleItem(const SignalRef& ref); + + /** 取消某信号的重采样勾选。 */ + void RemoveResampleItem(const SignalRef& ref); + + /** 取某信号的重采样勾选状态。 */ + bool ResampleEnabled(const SignalRef& ref) const + { + return FindResampleItem(ref) != nullptr; + } + + /** 重采样浮动面板的 UI 状态(展开/折起 + 位置)。 */ + LanePanelState resamplePanel; + + /** @brief 「重采样」面板内容的滚动偏移(客户区像素,0 = 顶部)。 */ + float resampleScroll = 0.0f; + /** * @brief 「自由标记」面板里被**收起**的信号分组(点击分组栏切换)。 * diff --git a/Utilities/Inc/DataModelInternal.hpp b/Utilities/Inc/DataModelInternal.hpp index c7e59d2..7e7cfe2 100644 --- a/Utilities/Inc/DataModelInternal.hpp +++ b/Utilities/Inc/DataModelInternal.hpp @@ -87,6 +87,7 @@ namespace jdi prune(lane.edgeCenterItems); prune(lane.weakPeakItems); prune(lane.freeMarkerItems); + prune(lane.resampleItems); lane.freeMarkerCollapsedGroups.erase( std::remove_if(lane.freeMarkerCollapsedGroups.begin(), lane.freeMarkerCollapsedGroups.end(), [&inLane](const SignalRef& ref) { return !inLane(ref); }), @@ -140,6 +141,11 @@ namespace jdi bool freeMarkerShowDelta = false; bool freeMarkerShowEnum = false; std::vector freeMarkerCollapsedGroups; + /** @brief 「重采样」开关与逐信号参数(详见 `Lane::resample`)。 */ + bool resample = false; + std::vector resampleItems; + LanePanelState resamplePanel; + float resampleScroll = 0.0f; /** @brief 「均值滤波」面板内容的滚动偏移(详见 `Lane::meanFilterScroll`)。 */ float meanFilterScroll = 0.0f; /** @brief 「中心轴边沿计数」面板内容的滚动偏移(详见 `Lane::edgeCenterScroll`)。 */ @@ -199,6 +205,10 @@ namespace jdi carry.freeMarkerShowDelta = lane.freeMarkerShowDelta; carry.freeMarkerShowEnum = lane.freeMarkerShowEnum; carry.freeMarkerCollapsedGroups = lane.freeMarkerCollapsedGroups; + carry.resample = lane.resample; + carry.resampleItems = lane.resampleItems; + carry.resamplePanel = lane.resamplePanel; + carry.resampleScroll = lane.resampleScroll; for (std::size_t signalIndex = 0; signalIndex < lane.signals.size(); ++signalIndex) { LaneCarry entry = carry; @@ -271,6 +281,13 @@ namespace jdi lane.freeMarkerShowEnum = carry.freeMarkerShowEnum; lane.freeMarkerCollapsedGroups = carry.freeMarkerCollapsedGroups; lane.meanFilterScroll = carry.meanFilterScroll; + lane.resample = carry.resample; + lane.resampleItems = carry.resampleItems; + lane.resamplePanel.open = carry.resamplePanel.open; + lane.resamplePanel.collapsed = carry.resamplePanel.collapsed; + lane.resamplePanel.x = carry.resamplePanel.x; + lane.resamplePanel.y = carry.resamplePanel.y; + lane.resampleScroll = carry.resampleScroll; }; // 收集全部可见信号引用。 diff --git a/Utilities/Inc/FormulaEval.hpp b/Utilities/Inc/FormulaEval.hpp new file mode 100644 index 0000000..f890cd9 --- /dev/null +++ b/Utilities/Inc/FormulaEval.hpp @@ -0,0 +1,58 @@ +#ifndef __FORMULA_EVAL_HPP__ +#define __FORMULA_EVAL_HPP__ + +// 「公式映射」的表达式编译与求值(信号样式区的 `Y = …` 输入框)。 +// +// 语法(刻意保持极小,避免引入脚本引擎与额外的体积): +// expr := term (('+' | '-') term)* +// term := unary (('*' | '/') unary)* +// unary := ('+' | '-') unary | primary +// primary := number | '(' expr ')' | variable +// number := 十进制小数(支持 e/E 指数) +// variable:= `X` / `x` / `f` / `f(t)` —— 都表示"当前采样点的值" +// 另外支持常量 `pi`。 +// +// ⚠️ 求值走 `thread_local` 的单条缓存(按 `std::wstring` 的 data 指针 + 内容判定): +// 绘制循环里同一条信号的样式对象是稳定的,因此每个信号只需编译一次; +// **绝不能**每个采样点都重新解析(千万级采样会慢到不可用)。 + +#include + +namespace jdi +{ + /** + * @brief 校验公式文本是否可以解析。 + * + * 空文本是合法输入(表示"不做映射")。 + * + * @param [IN] text 公式文本(不含 `Y =` 前缀)。 + * @param [OUT] error 解析失败时的原因(成功时清空),允许为空。 + * @return 语法合法返回 true。 + */ + bool ValidateFormula(const std::wstring& text, std::wstring* error); + + /** + * @brief 计算公式在给定采样值处的取值。 + * + * 文本为空或无法解析时**原样返回** `x`(保证绘制不会因为非法公式而消失)。 + * 计算结果非有限(如 1/0)时返回 NaN(保持断线语义)。 + * + * @param [IN] text 公式文本。 + * @param [IN] x 采样值(公式中的 `f(t)` / `X`)。 + * @return 映射后的值。 + */ + double EvaluateFormula(const std::wstring& text, double x); + + /** + * @brief 把用户输入里的自变量写法统一成 `f(t)`(保存前调用)。 + * + * 规则:把独立的 `X` / `x` / `f` 记号改写为 `f(t)`;`f(t)` 原样保留。 + * 只做记号级替换(不会动标识符中间或数字里的字符)。 + * + * @param [IN] text 用户输入的公式。 + * @return 归一化后的文本。 + */ + std::wstring NormalizeFormulaText(const std::wstring& text); +} // namespace jdi + +#endif // __FORMULA_EVAL_HPP__ diff --git a/Utilities/Inc/Utf8Util.hpp b/Utilities/Inc/Utf8Util.hpp index 7def786..07c26f6 100644 --- a/Utilities/Inc/Utf8Util.hpp +++ b/Utilities/Inc/Utf8Util.hpp @@ -121,6 +121,16 @@ namespace jdi */ std::wstring FormatDurationSeconds(double seconds); + /** + * @brief 把频率格式化为带自适应单位的文本(Hz / kHz / MHz / GHz,保留小数)。 + * + * 用于「重采样」面板与派生信号名称(`<原信号> (重采样 1.2 kHz)`)。 + * + * @param [IN] hertz 频率(Hz)。 + * @return 形如 "1.2 kHz" 的文本;非法值返回 "—"。 + */ + std::wstring FormatFrequency(double hertz); + /** * @brief 计算“美观”的坐标轴刻度步长(1/2/5 乘以 10 的整数次幂)。 * @param [IN] range 坐标轴数值跨度,必须为正数。 diff --git a/Utilities/Src/DataModel.cpp b/Utilities/Src/DataModel.cpp index 419918f..ee00c33 100644 --- a/Utilities/Src/DataModel.cpp +++ b/Utilities/Src/DataModel.cpp @@ -421,6 +421,23 @@ namespace jdi return true; } + bool DataSet::AppendGroupAxis(int groupIndex, TimeAxis&& axis, std::size_t& axisIndex) + { + axisIndex = 0; + if (groupIndex < 0 || groupIndex >= static_cast(m_groups.size())) + { + return false; + } + if (axis.time.empty()) + { + return false; + } + SignalGroup& group = m_groups[static_cast(groupIndex)]; + group.axes.push_back(std::move(axis)); + axisIndex = group.axes.size() - 1; + return true; + } + bool DataSet::InsertDerivedSignal(int groupIndex, int afterSignalIndex, SignalData&& signal, int& newSignalIndex) { @@ -1396,6 +1413,43 @@ namespace jdi weakPeakItems.end()); } + // ---------------------------------------------------------------- 重采样 + + const Lane::ResampleItem* Lane::FindResampleItem(const SignalRef& ref) const + { + for (const ResampleItem& item : resampleItems) + { + if (item.ref == ref) + { + return &item; + } + } + return nullptr; + } + + Lane::ResampleItem& Lane::EnsureResampleItem(const SignalRef& ref) + { + for (ResampleItem& item : resampleItems) + { + if (item.ref == ref) + { + return item; + } + } + ResampleItem item; + item.ref = ref; + resampleItems.push_back(item); + return resampleItems.back(); + } + + void Lane::RemoveResampleItem(const SignalRef& ref) + { + resampleItems.erase( + std::remove_if(resampleItems.begin(), resampleItems.end(), + [&ref](const ResampleItem& item) { return item.ref == ref; }), + resampleItems.end()); + } + // ---------------------------------------------------------------- 自由标记 namespace diff --git a/Utilities/Src/DataModelTab.cpp b/Utilities/Src/DataModelTab.cpp index 8054ced..de8dc57 100644 --- a/Utilities/Src/DataModelTab.cpp +++ b/Utilities/Src/DataModelTab.cpp @@ -145,6 +145,7 @@ namespace jdi purgeRefs(lane.meanFilterItems); purgeRefs(lane.edgeCenterItems); purgeRefs(lane.weakPeakItems); + purgeRefs(lane.resampleItems); } // 被移除信号若独占了某个网格,该网格随之消失(与取消勾选的行为一致)。 m_lanes.erase(std::remove_if(m_lanes.begin(), m_lanes.end(), @@ -449,6 +450,9 @@ namespace jdi lane.freeMarkerShowEnum = source.freeMarkerShowEnum; lane.freeMarkerPanel = source.freeMarkerPanel; lane.freeMarkerOpacity = source.freeMarkerOpacity; + lane.resample = source.resample; + lane.resamplePanel = source.resamplePanel; + lane.resampleScroll = source.resampleScroll; // 拆分后每个子网格只保留自己那个信号的均值滤波 / 中心轴 / 弱峰调整 / 自由标记条目。 for (const Lane::MeanFilterItem& item : source.meanFilterItems) { @@ -487,6 +491,13 @@ namespace jdi lane.freeMarkerItems.push_back(item); } } + for (const Lane::ResampleItem& item : source.resampleItems) + { + if (item.ref == ref) + { + lane.resampleItems.push_back(item); + } + } for (const SignalRef& collapsed : source.freeMarkerCollapsedGroups) { if (collapsed == ref) @@ -557,6 +568,14 @@ namespace jdi ++item.ref.signalIndex; } } + // 「重采样」条目同样持有信号引用。 + for (Lane::ResampleItem& item : lane.resampleItems) + { + if (item.ref.groupIndex == groupIndex && item.ref.signalIndex >= insertPos) + { + ++item.ref.signalIndex; + } + } } } @@ -691,7 +710,36 @@ namespace jdi double signalHigh = signal->maxValue; if (style != nullptr) { - TransformValueRange(*style, signalLow, signalHigh); + if (style->HasFormula()) + { + // ⚠️ 「公式映射」不保证单调(`x*x` / `1/x`)⇒ 端点映射会算错, + // 只能逐点扫描(只在自适应重算时发生,不是每帧路径)。 + double formulaLow = std::numeric_limits::infinity(); + double formulaHigh = -std::numeric_limits::infinity(); + for (const double raw : signal->values) + { + if (!std::isfinite(raw)) + { + continue; + } + const double mapped = ApplyDrawTransform(*style, raw); + if (!std::isfinite(mapped)) + { + continue; + } + formulaLow = std::min(formulaLow, mapped); + formulaHigh = std::max(formulaHigh, mapped); + } + if (std::isfinite(formulaLow) && std::isfinite(formulaHigh)) + { + signalLow = formulaLow; + signalHigh = formulaHigh; + } + } + else + { + TransformValueRange(*style, signalLow, signalHigh); + } } low = std::min(low, signalLow); high = std::max(high, signalHigh); diff --git a/Utilities/Src/FormulaEval.cpp b/Utilities/Src/FormulaEval.cpp new file mode 100644 index 0000000..c554e70 --- /dev/null +++ b/Utilities/Src/FormulaEval.cpp @@ -0,0 +1,586 @@ +// 「公式映射」表达式的编译 / 求值实现(详见 `FormulaEval.hpp`)。 + +#include "FormulaEval.hpp" + +#include +#include +#include +#include +#include +#include + +namespace jdi +{ + namespace + { + /** 公式允许的最大长度(防止恶意/误粘贴的超长文本导致解析耗时)。 */ + constexpr std::size_t kMaxFormulaLength = 512; + /** 允许的最大记号数(与长度一起兜底)。 */ + constexpr std::size_t kMaxFormulaTokens = 512; + + /** 逆波兰式里的一个运算单元。 */ + struct FormulaOp + { + enum class Kind : int + { + NUMBER = 0, /**< 常量。 */ + VARIABLE, /**< 自变量(采样值)。 */ + ADD, + SUBTRACT, + MULTIPLY, + DIVIDE, + NEGATE + }; + + Kind kind = Kind::NUMBER; + double value = 0.0; + }; + + /** 单个自变量占用的栈槽(求值时用)。 */ + constexpr std::size_t kMaxStack = 64; + + /** 记号类型。 */ + enum class TokenKind : int + { + NUMBER = 0, + VARIABLE, + PLUS, + MINUS, + STAR, + SLASH, + LEFT_PAREN, + RIGHT_PAREN, + END + }; + + struct Token + { + TokenKind kind = TokenKind::END; + double value = 0.0; + }; + + /** 把一个公式文本切成记号序列(失败返回 false 并写 error)。 */ + bool Tokenize(const std::wstring& text, std::vector& tokens, std::wstring* error) + { + tokens.clear(); + std::size_t index = 0; + const std::size_t length = text.size(); + if (length > kMaxFormulaLength) + { + if (error != nullptr) + { + *error = L"公式过长(最多 512 个字符)。"; + } + return false; + } + while (index < length) + { + const wchar_t ch = text[index]; + if (std::iswspace(static_cast(ch)) != 0) + { + ++index; + continue; + } + if (ch == L'+') + { + tokens.push_back(Token{TokenKind::PLUS, 0.0}); + ++index; + } + else if (ch == L'-') + { + tokens.push_back(Token{TokenKind::MINUS, 0.0}); + ++index; + } + else if (ch == L'*') + { + tokens.push_back(Token{TokenKind::STAR, 0.0}); + ++index; + } + else if (ch == L'/') + { + tokens.push_back(Token{TokenKind::SLASH, 0.0}); + ++index; + } + else if (ch == L'(') + { + tokens.push_back(Token{TokenKind::LEFT_PAREN, 0.0}); + ++index; + } + else if (ch == L')') + { + tokens.push_back(Token{TokenKind::RIGHT_PAREN, 0.0}); + ++index; + } + else if ((ch >= L'0' && ch <= L'9') || ch == L'.') + { + // 数字:手工扫描,交给 `wcstod` 转换(保证与内联编辑的数值语义一致)。 + const std::size_t start = index; + while (index < length && + ((text[index] >= L'0' && text[index] <= L'9') || text[index] == L'.')) + { + ++index; + } + if (index < length && (text[index] == L'e' || text[index] == L'E')) + { + ++index; + if (index < length && (text[index] == L'+' || text[index] == L'-')) + { + ++index; + } + while (index < length && text[index] >= L'0' && text[index] <= L'9') + { + ++index; + } + } + const std::wstring literal = text.substr(start, index - start); + wchar_t* stop = nullptr; + const double value = std::wcstod(literal.c_str(), &stop); + if (stop == literal.c_str() || !std::isfinite(value)) + { + if (error != nullptr) + { + *error = L"数字格式不正确。"; + } + return false; + } + tokens.push_back(Token{TokenKind::NUMBER, value}); + } + else if (std::iswalpha(static_cast(ch)) != 0) + { + // 标识符:只允许 X / x / f / f(t) / pi。 + const std::size_t start = index; + while (index < length && std::iswalpha(static_cast(text[index])) != 0) + { + ++index; + } + std::wstring name = text.substr(start, index - start); + // 跳过空格后可能是 `(t)`(`f(t)` 的写法)。 + std::size_t look = index; + while (look < length && std::iswspace(static_cast(text[look])) != 0) + { + ++look; + } + if (look < length && text[look] == L'(') + { + std::size_t inner = look + 1; + while (inner < length && std::iswspace(static_cast(text[inner])) != 0) + { + ++inner; + } + if (inner < length && (text[inner] == L't' || text[inner] == L'T')) + { + ++inner; + while (inner < length && std::iswspace(static_cast(text[inner])) != 0) + { + ++inner; + } + if (inner < length && text[inner] == L')') + { + index = inner + 1; + } + } + } + for (wchar_t& letter : name) + { + letter = static_cast(std::towlower(static_cast(letter))); + } + if (name == L"x" || name == L"f") + { + tokens.push_back(Token{TokenKind::VARIABLE, 0.0}); + } + else if (name == L"pi") + { + tokens.push_back(Token{TokenKind::NUMBER, 3.14159265358979323846}); + } + else + { + if (error != nullptr) + { + *error = L"无法识别的标识符(自变量请写 f(t) 或 X)。"; + } + return false; + } + } + else + { + if (error != nullptr) + { + *error = L"公式含有无法识别的字符。"; + } + return false; + } + } + tokens.push_back(Token{TokenKind::END, 0.0}); + if (tokens.size() > kMaxFormulaTokens) + { + if (error != nullptr) + { + *error = L"公式过于复杂。"; + } + return false; + } + return true; + } + + /** 递归下降解析器(直接产出逆波兰式)。 */ + class Parser + { + public: + Parser(const std::vector& tokens, std::vector& output, std::wstring* error) + : m_tokens(tokens), m_output(output), m_error(error) + { + } + + bool Parse() + { + if (!ParseExpression()) + { + return false; + } + if (Current().kind != TokenKind::END) + { + return SetError(L"公式存在多余的内容(请检查括号是否配对)。"); + } + return true; + } + + private: + const Token& Current() const { return m_tokens[m_position]; } + void Advance() + { + if (m_position + 1 < m_tokens.size()) + { + ++m_position; + } + } + bool SetError(const wchar_t* message) + { + if (m_error != nullptr) + { + *m_error = message; + } + return false; + } + + bool ParseExpression() + { + if (!ParseTerm()) + { + return false; + } + while (Current().kind == TokenKind::PLUS || Current().kind == TokenKind::MINUS) + { + const bool add = (Current().kind == TokenKind::PLUS); + Advance(); + if (!ParseTerm()) + { + return false; + } + m_output.push_back(FormulaOp{add ? FormulaOp::Kind::ADD + : FormulaOp::Kind::SUBTRACT, + 0.0}); + } + return true; + } + + bool ParseTerm() + { + if (!ParseUnary()) + { + return false; + } + while (Current().kind == TokenKind::STAR || Current().kind == TokenKind::SLASH) + { + const bool multiply = (Current().kind == TokenKind::STAR); + Advance(); + if (!ParseUnary()) + { + return false; + } + m_output.push_back(FormulaOp{multiply ? FormulaOp::Kind::MULTIPLY + : FormulaOp::Kind::DIVIDE, + 0.0}); + } + return true; + } + + bool ParseUnary() + { + if (Current().kind == TokenKind::PLUS) + { + Advance(); + return ParseUnary(); + } + if (Current().kind == TokenKind::MINUS) + { + Advance(); + if (!ParseUnary()) + { + return false; + } + m_output.push_back(FormulaOp{FormulaOp::Kind::NEGATE, 0.0}); + return true; + } + return ParsePrimary(); + } + + bool ParsePrimary() + { + const Token& token = Current(); + if (token.kind == TokenKind::NUMBER) + { + m_output.push_back(FormulaOp{FormulaOp::Kind::NUMBER, token.value}); + Advance(); + return true; + } + if (token.kind == TokenKind::VARIABLE) + { + m_output.push_back(FormulaOp{FormulaOp::Kind::VARIABLE, 0.0}); + Advance(); + return true; + } + if (token.kind == TokenKind::LEFT_PAREN) + { + Advance(); + if (!ParseExpression()) + { + return false; + } + if (Current().kind != TokenKind::RIGHT_PAREN) + { + return SetError(L"缺少右括号。"); + } + Advance(); + return true; + } + return SetError(L"公式不完整或存在多余的运算符。"); + } + + const std::vector& m_tokens; + std::vector& m_output; + std::wstring* m_error; + std::size_t m_position = 0; + }; + + /** 编译结果(逆波兰式);空表示"不可用"。 */ + using Program = std::vector; + + /** 线程内的单条编译缓存(按 data 指针 + 内容判定)。 */ + struct FormulaCache + { + const wchar_t* data = nullptr; + std::size_t size = 0; + std::wstring text; + Program program; + bool compiled = false; + bool valid = false; + }; + + /** + * @brief 取(必要时编译)公式对应的程序。 + * @return 合法时返回程序指针;空文本或非法时返回 nullptr。 + */ + const Program* AcquireProgram(const std::wstring& text) + { + if (text.empty()) + { + return nullptr; + } + static thread_local FormulaCache cache; + if (cache.data == text.data() && cache.size == text.size() && cache.compiled) + { + return cache.valid ? &cache.program : nullptr; + } + // 指针变化:先比内容,内容相同则直接复用(避免同一公式被多个样式副本反复编译)。 + if (cache.compiled && cache.text == text) + { + cache.data = text.data(); + cache.size = text.size(); + return cache.valid ? &cache.program : nullptr; + } + cache.text = text; + cache.data = text.data(); + cache.size = text.size(); + cache.program.clear(); + cache.valid = false; + cache.compiled = true; + + std::vector tokens; + std::wstring error; + if (!Tokenize(text, tokens, &error)) + { + return nullptr; + } + Parser parser(tokens, cache.program, &error); + if (!parser.Parse()) + { + cache.program.clear(); + return nullptr; + } + cache.valid = true; + return &cache.program; + } + } // namespace + + bool ValidateFormula(const std::wstring& text, std::wstring* error) + { + if (error != nullptr) + { + error->clear(); + } + if (text.empty()) + { + return true; + } + std::vector tokens; + if (!Tokenize(text, tokens, error)) + { + return false; + } + std::vector program; + Parser parser(tokens, program, error); + if (!parser.Parse()) + { + return false; + } + return true; + } + + double EvaluateFormula(const std::wstring& text, double x) + { + const Program* program = AcquireProgram(text); + if (program == nullptr) + { + return x; // 空 / 非法:不做映射(保证波形照常显示) + } + double stack[kMaxStack]; + std::size_t depth = 0; + for (const FormulaOp& op : *program) + { + switch (op.kind) + { + case FormulaOp::Kind::NUMBER: + case FormulaOp::Kind::VARIABLE: + if (depth >= kMaxStack) + { + return std::numeric_limits::quiet_NaN(); + } + stack[depth++] = (op.kind == FormulaOp::Kind::NUMBER) ? op.value : x; + break; + case FormulaOp::Kind::NEGATE: + if (depth < 1) + { + return std::numeric_limits::quiet_NaN(); + } + stack[depth - 1] = -stack[depth - 1]; + break; + default: + { + if (depth < 2) + { + return std::numeric_limits::quiet_NaN(); + } + const double b = stack[depth - 1]; + const double a = stack[depth - 2]; + --depth; + double result = 0.0; + switch (op.kind) + { + case FormulaOp::Kind::ADD: + result = a + b; + break; + case FormulaOp::Kind::SUBTRACT: + result = a - b; + break; + case FormulaOp::Kind::MULTIPLY: + result = a * b; + break; + case FormulaOp::Kind::DIVIDE: + result = a / b; + break; + default: + break; + } + stack[depth - 1] = result; + break; + } + } + } + if (depth != 1) + { + return std::numeric_limits::quiet_NaN(); + } + return std::isfinite(stack[0]) ? stack[0] : std::numeric_limits::quiet_NaN(); + } + + std::wstring NormalizeFormulaText(const std::wstring& text) + { + std::wstring output; + output.reserve(text.size() + 8); + const std::size_t length = text.size(); + std::size_t index = 0; + while (index < length) + { + const wchar_t ch = text[index]; + if (std::iswalpha(static_cast(ch)) == 0) + { + output.push_back(ch); + ++index; + continue; + } + const std::size_t start = index; + while (index < length && std::iswalpha(static_cast(text[index])) != 0) + { + ++index; + } + std::wstring name = text.substr(start, index - start); + // 已经是 `f(t)`:跳过随后的 `(t)`,整体原样输出(统一写成小写形式)。 + std::size_t look = index; + while (look < length && std::iswspace(static_cast(text[look])) != 0) + { + ++look; + } + bool hasArgument = false; + if (look < length && text[look] == L'(') + { + std::size_t inner = look + 1; + while (inner < length && std::iswspace(static_cast(text[inner])) != 0) + { + ++inner; + } + if (inner < length && (text[inner] == L't' || text[inner] == L'T')) + { + ++inner; + while (inner < length && std::iswspace(static_cast(text[inner])) != 0) + { + ++inner; + } + if (inner < length && text[inner] == L')') + { + hasArgument = true; + index = inner + 1; + } + } + } + std::wstring lowered = name; + for (wchar_t& letter : lowered) + { + letter = static_cast(std::towlower(static_cast(letter))); + } + if (hasArgument && (lowered == L"f" || lowered == L"x")) + { + // 统一写成小写规范形式 `f(t)`(保留用户其余写法)。 + output += L"f(t)"; + } + else if (!hasArgument && (lowered == L"x" || lowered == L"f")) + { + output += L"f(t)"; + } + else + { + output += name; + } + } + return output; + } +} // namespace jdi diff --git a/Utilities/Src/Utf8Util.cpp b/Utilities/Src/Utf8Util.cpp index 52bb505..6c4ba53 100644 --- a/Utilities/Src/Utf8Util.cpp +++ b/Utilities/Src/Utf8Util.cpp @@ -471,6 +471,32 @@ namespace jdi return FormatValue(std::fabs(seconds)); } + std::wstring FormatFrequency(double hertz) + { + if (!std::isfinite(hertz) || hertz <= 0.0) + { + return L"—"; + } + const wchar_t* unit = L"Hz"; + double scaled = hertz; + if (hertz >= 1.0e9) + { + unit = L"GHz"; + scaled = hertz / 1.0e9; + } + else if (hertz >= 1.0e6) + { + unit = L"MHz"; + scaled = hertz / 1.0e6; + } + else if (hertz >= 1.0e3) + { + unit = L"kHz"; + scaled = hertz / 1.0e3; + } + return FormatValue(scaled) + L" " + unit; + } + std::wstring FormatByteSize(unsigned long long bytes) { // 单位自适应:GB / MB / KB(阈值按 1024 进制)。 constexpr double kKilo = 1024.0;