diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index b4e43e7..369fdb0 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -1794,6 +1794,47 @@ namespace jdi std::vector& band, std::vector& bandBreaks, bool& bandMode, bool& bandSpiky) const; + /** + * @brief 「公式映射」信号的逐分组包络缓存条目(见 `AcquireFormulaEnvelope`)。 + * + * 键 = 信号 + 采样数组 + 采样数 + 公式文本 + 影响数值的样式字段。 + * `minimums` / `maximums` 存的是**已经套用完整绘制变换**(公式 → 系数 → 绝对值 → + * 镜像 → Y 偏移)之后、每 `stride` 个采样一个分组的极值(无有效值的分组为 NaN)。 + */ + struct FormulaEnvelopeEntry + { + const void* signal = nullptr; /**< `resolved.data`(区分不同信号)。 */ + const void* values = nullptr; /**< `values.data()`(区分重新加载的数据)。 */ + std::size_t count = 0; /**< 参与统计的采样数。 */ + std::size_t stride = 0; /**< 每个分组覆盖的采样数。 */ + std::wstring formula; /**< 公式文本(空 = 不适用)。 */ + float gain = 1.0f; + bool absolute = false; + bool mirrorVertical = false; + float offsetY = 0.0f; + std::vector minimums; /**< 每组的绘制值下限。 */ + std::vector maximums; /**< 每组的绘制值上限。 */ + bool valid = false; /**< 是否已成功建立。 */ + }; + + /** + * @brief 取得(必要时建立)「公式映射」信号的逐分组包络。 + * + * ⚠️⚠️ **为什么必须单独建一层**:`SignalData::coarseMin/coarseMax` 只存**原始采样**的 + * 分组极值,只有在数值变换**保序**时才能直接缩放使用(见 `DrawTransformOrderPreserving`)。 + * 公式(如 `f(t)^2`、`1/f(t)`)不保序,旧实现在这种情况会退化成**逐点扫描整条信号**: + * `VERY_BIG_DATA` 单条 2580 万采样时,一次折线构建 ≈ 850ms(缩放一帧就卡一次)。 + * 这里把“变换后的分组极值”算**一次**缓存起来,之后每帧/每次缩放只按分组聚合 + * (几十微秒),且极值仍是逐点统计出来的 ⇒ **不会漏掉尖峰**。 + * + * 只在公式信号上使用;无公式时返回 nullptr(继续走既有的 `coarseMin/Max` 快路径)。 + * + * @param [IN] resolved 已解析的信号视图。 + * @return 缓存条目指针(内容有效);不适用或无法建立时返回 nullptr。 + */ + const FormulaEnvelopeEntry* AcquireFormulaEnvelope(const ResolvedSignal& resolved) const; + + /** * @brief 绘制单个网格。 * @param [IN,OUT] g 绘图上下文。 @@ -3183,6 +3224,18 @@ namespace jdi std::vector m_cursorLabelRects; /**< 本次绘制已占用的取值标注矩形(用于避让重叠)。 */ std::vector m_lanes; /**< 当前网格绘制信息集合。 */ + /** + * @brief 「网格幅值范围」缓存指纹(见 `BuildLaneVisuals`)。 + * + * `ComputeLaneRange()` 对启用了「公式映射」的信号必须**逐点扫描整条信号** + *(公式不保证单调,端点映射会算错)。而它在 `BuildLaneVisuals` 里**每帧**为 + * 每个网格各调一次 ⇒ 千万级采样时每帧几百毫秒。范围只依赖「可见信号 + 数值样式 + + * 数据本身」,与视界无关,因此按指纹缓存:指纹一致就复用 `m_laneRangeYMin/Max`。 + */ + std::uint64_t m_laneRangeKey = 0; + std::vector m_laneRangeYMin; /**< 各网格缓存的 yMin(与上述指纹配套)。 */ + std::vector m_laneRangeYMax; /**< 各网格缓存的 yMax(与上述指纹配套)。 */ + /** * @brief 「边沿统计」的累积状态(普通边沿计数与中心轴边沿计数**共用**)。 * @@ -3519,6 +3572,13 @@ namespace jdi const LaneVisual& lane) const; mutable std::deque m_polylineCache; /**< 折线顶点缓存(按几何键复用)。 */ + /** + * @brief 「公式映射」信号的逐分组包络缓存(见 `AcquireFormulaEnvelope`)。 + * + * 只在信号带公式时建立;条目键含公式文本与影响数值的样式字段, + * 公式 / 系数 / 镜像等变化时自然失效。用 `deque` 保证追加时已有引用不失效。 + */ + mutable std::deque m_formulaEnvelopes; /** * @brief 折线顶点缓存的**容量上限**(条目数)。 * diff --git a/Components/PlotView/Inc/PlotViewInternal.hpp b/Components/PlotView/Inc/PlotViewInternal.hpp index c0be8e2..0be6d79 100644 --- a/Components/PlotView/Inc/PlotViewInternal.hpp +++ b/Components/PlotView/Inc/PlotViewInternal.hpp @@ -607,6 +607,18 @@ namespace jdi inline const double kResampleIntervalMin = 1.0e-9; inline const double kResampleIntervalMax = 1.0e6; + // ---- 「公式映射」的分组包络缓存(见 `PlotView::AcquireFormulaEnvelope`)---- + /** + * @brief 每个信号的包络分组数上限(分组宽度按采样数反算)。 + * + * 65536 组 × 2(min/max)× 4B ≈ 512KB/信号;对 2580 万采样 ⇒ 每组 ~394 个采样, + * 而屏幕上一个像素列覆盖上万采样 ⇒ 分组粒度造成的包络误差远小于 1 像素。 + */ + inline const std::size_t kFormulaEnvelopeBuckets = 65536; + /** @brief 包络缓存最多保存的信号数(超出即整表清空,避免无界增长)。 */ + inline const std::size_t kFormulaEnvelopeCacheLimit = 16; + + // ---- 「均值滤波」面板的两行参数滑块(用户 2026-10-30 要求)---- /** * 标签一律**不带 `X` / `N`**:滑块的量程本身已经说明是数值,符号只会让标签变长。 @@ -1294,11 +1306,34 @@ namespace jdi return FormatValue(seconds) + L"s"; } + /** + * @brief 把时间间隔四舍五入到 **3 位有效数字**(供「重采样」面板显示)。 + * + * 计算出来的数据间隔常常带一长串浮点尾数(如 `5.0000158us`)。整串显示既没有意义, + * 又会把面板撑得很宽(还要在后面追加「(频率)」尾缀,用户 2026-11-03 要求)。 + * 3 位有效数字足以区分相邻的采样间隔,且让显示稳定。 + * + * @param [IN] seconds 时间间隔(秒)。 + * @return 舍入后的间隔;非法/非正输入原样返回。 + */ + inline double RoundIntervalForDisplay(double seconds) + { + if (!std::isfinite(seconds) || seconds <= 0.0) + { + return seconds; + } + const double magnitude = std::floor(std::log10(seconds)); + const double scale = std::pow(10.0, 2.0 - magnitude); + const double rounded = std::round(seconds * scale) / scale; + return (rounded > 0.0) ? rounded : seconds; + } + /** * @brief 时间间隔的显示文本(**不带** "Δt:" 前缀)。 * * 「重采样」面板的「最大 / 最小数据间隔」两行用它:单位随间隔长短自适应 - *(`FormatDurationCompound`:1h 1min 2s / 5s 60ms / 120us …)。 + *(`FormatDurationCompound`:1h 1min 2s / 5s 60ms / 120us …), + * 且先舍入到 3 位有效数字(见 `RoundIntervalForDisplay`),**不出现科学计数法**。 * * @param [IN] seconds 时间间隔(秒)。 * @return 文本。 @@ -1309,7 +1344,7 @@ namespace jdi { return L"—"; } - return FormatDurationCompound(seconds); + return FormatDurationCompound(RoundIntervalForDisplay(seconds)); } /** diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index e485d16..6d9b8fb 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -460,6 +460,76 @@ namespace jdi const float scrollOffset = Tab().LaneScroll(); + // ⚠️⚠️ **幅值范围(`ComputeLaneRange`)必须缓存**(用户 2026-11-03 报告“只是点进过 + // 公式输入框就卡死”):本函数**每帧**都会为**每个网格**调用一次 `ComputeLaneRange`, + // 而启用了「公式映射」的信号在里面无法用 min/max 端点映射(公式不保证单调), + // 只能**逐点扫描整条信号** —— `VERY_BIG_DATA` 单条 2580 万采样时单帧就是几百毫秒, + // 拖拽时每帧都付一遍 ⇒ 完全没法用。 + // + // 幅值范围只取决于「可见信号集合 + 影响数值的样式字段 + 信号数据本身」, + // **与视界/缩放无关**,因此用指纹缓存住:指纹不变就直接复用上一帧的结果。 + // 指纹包含数据指针/长度/统计值与公式文本 ⇒ 重新加载数据、改样式、改公式都会自动失效。 + std::uint64_t rangeKey = 1469598103934665603ull; + const auto mixRange = [&rangeKey](std::uint64_t value) + { + rangeKey ^= value; + rangeKey *= 1099511628211ull; + }; + const auto mixRangeFloat = [&mixRange](float value) + { + std::uint32_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + mixRange(bits); + }; + const auto mixRangeDouble = [&mixRange](double value) + { + std::uint64_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + mixRange(bits); + }; + mixRange(reinterpret_cast(m_dataSet)); + mixRange(static_cast(count)); + for (const Lane& lane : lanes) + { + mixRange(lane.autoRange ? 1ull : 0ull); + mixRangeDouble(lane.yMin); + mixRangeDouble(lane.yMax); + mixRange(static_cast(lane.signals.size())); + for (const SignalRef& ref : lane.signals) + { + mixRange(static_cast(ref.groupIndex + 2)); + mixRange(static_cast(ref.signalIndex + 2)); + mixRange(m_tab->IsVisible(ref) ? 1ull : 0ull); + const SignalData* signal = m_dataSet->Resolve(ref); + const SignalStyle* style = m_dataSet->Style(ref); + mixRange(reinterpret_cast(signal)); + mixRange(reinterpret_cast(style)); + if (signal != nullptr) + { + mixRange(reinterpret_cast(signal->values.data())); + mixRange(static_cast(signal->values.size())); + mixRange(static_cast(signal->finiteCount)); + mixRangeDouble(signal->minValue); + mixRangeDouble(signal->maxValue); + } + if (style != nullptr) + { + // 只有**影响数值范围**的字段入键(颜色/线宽等与范围无关)。 + mixRangeFloat(style->gain); + mixRange(style->absolute ? 1ull : 0ull); + mixRange(style->mirrorVertical ? 1ull : 0ull); + mixRangeFloat(style->offsetY); + for (const wchar_t character : style->formula) + { + mixRange(static_cast(static_cast(character))); + } + mixRange(0x2Bull); // 公式结尾标记(避免 "f(t)" 与 "f(t)+" 撞键) + } + } + } + const bool reuseRange = (rangeKey == m_laneRangeKey) && + (m_laneRangeYMin.size() == count) && (m_laneRangeYMax.size() == count); + m_lanes.resize(count); for (std::size_t index = 0; index < count; ++index) { @@ -468,13 +538,33 @@ namespace jdi m_plotRect.X, m_plotRect.Y + static_cast(index) * (laneHeight + gap) - scrollOffset, m_plotRect.Width, laneHeight); - m_tab->ComputeLaneRange(*m_dataSet, static_cast(index), visual.yMin, visual.yMax); + if (reuseRange) + { + visual.yMin = m_laneRangeYMin[index]; + visual.yMax = m_laneRangeYMax[index]; + } + else + { + m_tab->ComputeLaneRange(*m_dataSet, static_cast(index), visual.yMin, visual.yMax); + } if (!(visual.yMax > visual.yMin)) { visual.yMin = 0.0; visual.yMax = 1.0; } } + // 记录本帧的幅值范围,供下一帧命中(`ComputeLaneRange` 对公式信号是 O(采样数))。 + if (!reuseRange) + { + m_laneRangeYMin.resize(count); + m_laneRangeYMax.resize(count); + for (std::size_t index = 0; index < count; ++index) + { + m_laneRangeYMin[index] = m_lanes[index].yMin; + m_laneRangeYMax[index] = m_lanes[index].yMax; + } + m_laneRangeKey = rangeKey; + } } float PlotView::TimeToX(double time) const diff --git a/Components/PlotView/Src/PlotViewInteraction.cpp b/Components/PlotView/Src/PlotViewInteraction.cpp index f48b3a8..ead88b5 100644 --- a/Components/PlotView/Src/PlotViewInteraction.cpp +++ b/Components/PlotView/Src/PlotViewInteraction.cpp @@ -2474,7 +2474,7 @@ namespace jdi { continue; } - // 点击「重采样间隔」输入框 → 编辑秒数。 + // 点击「重采样间隔」输入框 → 编辑**秒**数(不带单位、不用科学计数法)。 if (rowIndex < layout.rowIntervalField.size() && layout.rowIntervalField[rowIndex].Width > 0.0f && UiPointInRect(layout.rowIntervalField[rowIndex], fx, fy)) @@ -2483,13 +2483,11 @@ namespace jdi 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); + FormatDurationSeconds(item->interval), field); m_dragMode = PlotDragMode::NONE; return true; } - // 点击「重采样频率」输入框 → 编辑频率(Hz,与间隔联动)。 + // 点击「重采样频率」输入框 → 编辑频率(Hz,与间隔联动;不带单位)。 if (rowIndex < layout.rowFrequencyField.size() && layout.rowFrequencyField[rowIndex].Width > 0.0f && UiPointInRect(layout.rowFrequencyField[rowIndex], fx, fy)) @@ -2498,9 +2496,8 @@ namespace jdi 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); + BeginEdit(PlotEditTarget::RESAMPLE_FREQUENCY, index, + FormatDecimal(hz, 12), field); m_dragMode = PlotDragMode::NONE; return true; } diff --git a/Components/PlotView/Src/PlotViewPaint.cpp b/Components/PlotView/Src/PlotViewPaint.cpp index 55e9e17..cd972f7 100644 --- a/Components/PlotView/Src/PlotViewPaint.cpp +++ b/Components/PlotView/Src/PlotViewPaint.cpp @@ -210,6 +210,84 @@ namespace jdi return &m_polylineCache.back(); } + const PlotView::FormulaEnvelopeEntry* PlotView::AcquireFormulaEnvelope( + const ResolvedSignal& resolved) const + { + if (resolved.data == nullptr || resolved.style == nullptr || !resolved.style->HasFormula()) + { + return nullptr; + } + const std::vector& values = resolved.data->values; + const std::size_t count = values.size(); + if (count == 0) + { + return nullptr; + } + const SignalStyle& style = *resolved.style; + const void* signalKey = static_cast(resolved.data); + const void* valuesKey = static_cast(values.data()); + + // 命中缓存:公式与全部影响数值的样式字段都一致时可直接复用。 + for (const FormulaEnvelopeEntry& entry : m_formulaEnvelopes) + { + if (entry.valid && entry.signal == signalKey && entry.values == valuesKey && + entry.count == count && entry.formula == style.formula && entry.gain == style.gain && + entry.absolute == style.absolute && entry.mirrorVertical == style.mirrorVertical && + entry.offsetY == style.offsetY) + { + return &entry; + } + } + + // 未命中:建立新条目(容量上限很小:只有“带公式”的信号才会进来)。 + if (m_formulaEnvelopes.size() >= kFormulaEnvelopeCacheLimit) + { + m_formulaEnvelopes.clear(); + } + m_formulaEnvelopes.emplace_back(); + FormulaEnvelopeEntry& entry = m_formulaEnvelopes.back(); + entry.signal = signalKey; + entry.values = valuesKey; + entry.count = count; + entry.formula = style.formula; + entry.gain = style.gain; + entry.absolute = style.absolute; + entry.mirrorVertical = style.mirrorVertical; + entry.offsetY = style.offsetY; + // 分组宽度:让分组数落在 `kFormulaEnvelopeBuckets` 量级(内存 = 2 × 分组数 × 4B)。 + entry.stride = std::max( + 1, (count + kFormulaEnvelopeBuckets - 1) / kFormulaEnvelopeBuckets); + const std::size_t buckets = (count + entry.stride - 1) / entry.stride; + entry.minimums.assign(buckets, std::numeric_limits::quiet_NaN()); + entry.maximums.assign(buckets, std::numeric_limits::quiet_NaN()); + + // ⚠️ **一次**全量扫描:逐点套用完整绘制变换后按分组取极值。 + // 之后每帧 / 每次缩放都只按分组聚合,代价与采样数无关。 + for (std::size_t index = 0; index < count; ++index) + { + const double mapped = ApplyDrawTransform(style, values[index]); + if (!std::isfinite(mapped)) + { + continue; + } + const std::size_t bucket = index / entry.stride; + const double value = mapped; + double& low = entry.minimums[bucket]; + double& high = entry.maximums[bucket]; + if (!(low <= high)) + { + // 该分组第一次写入(初值是 NaN)。 + low = value; + high = value; + continue; + } + low = std::min(low, value); + high = std::max(high, value); + } + entry.valid = true; + return &entry; + } + void PlotView::BuildPolyline(const ResolvedSignal& resolved, const LaneVisual& lane, std::vector& points, std::vector& breaks, std::vector& band, std::vector& bandBreaks, @@ -356,19 +434,38 @@ namespace jdi // 粗粒度包络层(若已构建):每列只需读取覆盖到的分组, // 数据量从“可见采样数”降到“像素列数 × 少量分组”, // 且分组内存的就是真实极值,绘制结果与逐点扫描完全一致。 - const std::vector& coarseMin = resolved.data->coarseMin; - const std::vector& coarseMax = resolved.data->coarseMax; - const std::size_t coarseStride = resolved.data->coarseStride; + const std::vector* coarseMinPtr = &resolved.data->coarseMin; + const std::vector* coarseMaxPtr = &resolved.data->coarseMax; + std::size_t coarseStride = resolved.data->coarseStride; // ⚠️ 粗粒度分组只存了逐组的 min/max:取绝对值 / 垂直镜像会把两者互换, // 此时分组层无法再给出正确的包络,必须回退到逐采样点变换(见 `DrawTransformOrderPreserving`)。 // Y 偏移是保序的(整体加常数),因此仍可用分组层 —— 只需把偏移一并加上。 - const bool hasCoarse = - (coarseStride > 0) && !coarseMin.empty() && (coarseMin.size() == coarseMax.size()) && - DrawTransformOrderPreserving(*resolved.style); + bool hasCoarse = (coarseStride > 0) && !coarseMinPtr->empty() && + (coarseMinPtr->size() == coarseMaxPtr->size()) && + DrawTransformOrderPreserving(*resolved.style); // 保序变换(正系数 + Y 偏移)下:分组极值 × 系数 + 偏移 即为变换后的极值 //(系数 1、偏移 0 时逐位不变)。 - const double coarseScale = static_cast(resolved.style->gain); - const double coarseOffset = static_cast(resolved.style->offsetY); + double coarseScale = static_cast(resolved.style->gain); + double coarseOffset = static_cast(resolved.style->offsetY); + if (!hasCoarse) + { + // ⚠️⚠️ **「公式映射」不能用 `coarseMin/Max` 直接缩放**(公式不保序), + // 但也不能因此退回「逐点扫描整条信号」:2580 万采样时一次折线构建 ≈850ms, + // 缩放 / 平移每重建一帧就卡一次(用户 2026-11-03 报告)。 + // 这里改用**变换后的分组包络**(按需算一次并缓存):极值仍是逐点统计的 + // ⇒ 不会漏尖峰,而每帧只按分组聚合。 + if (const FormulaEnvelopeEntry* envelope = AcquireFormulaEnvelope(resolved)) + { + coarseMinPtr = &envelope->minimums; + coarseMaxPtr = &envelope->maximums; + coarseStride = envelope->stride; + coarseScale = 1.0; // 变换已经烘进包络 + coarseOffset = 0.0; + hasCoarse = true; + } + } + const std::vector& coarseMin = *coarseMinPtr; + const std::vector& coarseMax = *coarseMaxPtr; bool inSegment = false; @@ -972,11 +1069,15 @@ namespace jdi PaintHint(g); // 网格 Y 轴上下限的输入框、以及「弱峰调整 → 迭代次数上限」的输入框都绘制在最上层 //(后者覆盖在浮动面板的滑块行之上)。 + // ⚠️ 「重采样」的两个输入框(间隔 / 频率)同样**必须**在这里绘制:面板的绘制分支 + // 在编辑态会跳过输入框本体(改由本函数画),漏掉这一项就会“双击后输入框消失”。 if (m_editTarget == PlotEditTarget::LANE_Y_MAX || m_editTarget == PlotEditTarget::LANE_Y_MIN || 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) { PaintEditField(g); } @@ -5355,15 +5456,31 @@ namespace jdi (resampleItem->maxInterval > 0.0) && (resampleItem->minInterval > 0.0); maxInterval = resampleItem->maxInterval; minInterval = resampleItem->minInterval; + // 「最大 / 最小数据间隔」= 自适应单位的**小数**文本(绝不用科学计数法), + // 末尾追加一个括号内的「对应频率」尾缀(单位在 Hz/kHz/MHz/GHz 之间自适应)。 + // ⚠️ 频率必须由**同一份舍入后的间隔**换算,否则两列会对不上 + // (例如显示 `5us` 却给出 `199.99937 kHz`)。 + const auto intervalWithFrequency = [](double seconds) -> std::wstring + { + const double shown = RoundIntervalForDisplay(seconds); + std::wstring text = FormatDurationCompound(shown); + if (std::isfinite(shown) && shown > 0.0) + { + text += L"("; + text += FormatFrequencyText(1.0 / shown); + text += L")"; + } + return text; + }; if (rowIndex < layout.rowInfoMaxInterval.size()) { paintInfoRow(layout.rowInfoMaxInterval[rowIndex], kResampleMaxIntervalLabel, - hasInterval ? FormatIntervalText(maxInterval) : L"—"); + hasInterval ? intervalWithFrequency(maxInterval) : L"—"); } if (rowIndex < layout.rowInfoMinInterval.size()) { paintInfoRow(layout.rowInfoMinInterval[rowIndex], kResampleMinIntervalLabel, - hasInterval ? FormatIntervalText(minInterval) : L"—"); + hasInterval ? intervalWithFrequency(minInterval) : L"—"); } const double interval = (resampleItem->interval > 0.0) ? resampleItem->interval @@ -5376,15 +5493,19 @@ namespace jdi (m_editLane == static_cast(index)) && (m_editMeanRef == ref); if (rowIndex < layout.rowIntervalField.size()) { + // 非编辑态:自适应单位 + 小数(与「最大/最小数据间隔」同一套单位形式)。 + // 编辑态:只显示**秒**数值(不带单位),由 `PaintEditField` 绘制。 paintInputRow(layout.rowIntervalField[rowIndex], kResampleIntervalLabel, - FormatValue(interval), editingInterval); + FormatIntervalText(interval), editingInterval); } if (rowIndex < layout.rowFrequencyField.size()) { const double hertz = (interval > 0.0) ? (1.0 / interval) : 0.0; + // 非编辑态:频率单位自适应(Hz/kHz/MHz/GHz);编辑态:只显示 Hz 数值。 paintInputRow(layout.rowFrequencyField[rowIndex], kResampleFrequencyLabel, - FormatFrequencyText(hertz), editingFrequency); + (hertz > 0.0) ? FormatFrequencyText(hertz) : L"—", + editingFrequency); } continue; } diff --git a/Components/PlotView/Src/PlotViewPanels.cpp b/Components/PlotView/Src/PlotViewPanels.cpp index 09371de..d414aa2 100644 --- a/Components/PlotView/Src/PlotViewPanels.cpp +++ b/Components/PlotView/Src/PlotViewPanels.cpp @@ -170,14 +170,17 @@ namespace jdi { // 「重采样」四行 = 「标签列 + 数值列」(两行统计 + 两行输入框), // 标签取四者中最宽的一个,数值列按间隔 / 频率的典型文本宽度取。 + // ⚠️ 「最大/最小数据间隔」行末尾还带「(频率)」尾缀(用户 2026-11-03 要求), + // 取值列必须按**含尾缀**的文本量宽,否则尾缀会被裁掉。 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")); + std::max(std::max(UiMeasureTextWidth(m_fonts->monoSmall, L"1.23456 GHz"), + UiMeasureTextWidth(m_fonts->monoSmall, L"1h 1min 2s")), + UiMeasureTextWidth(m_fonts->monoSmall, L"1h 1min 2s(1.23456 GHz)")); width = std::max(width, kEdgePanelPad * 2.0f * m_scale + resampleLabelColumn + 8.0f * m_scale + resampleValueColumn + (kFreeMarkerScrollWidth + 4.0f) * m_scale); diff --git a/Components/SignalPanel/Src/SignalPanelMenu.cpp b/Components/SignalPanel/Src/SignalPanelMenu.cpp index ea2a10e..87de773 100644 --- a/Components/SignalPanel/Src/SignalPanelMenu.cpp +++ b/Components/SignalPanel/Src/SignalPanelMenu.cpp @@ -39,9 +39,12 @@ namespace jdi style->formula.empty() ? std::wstring(L"f(t)") : style->formula; MenuItem formulaEdit; formulaEdit.kind = MenuItemKind::EDIT; - formulaEdit.label = L"Y ="; // 输入框独占右侧剩余整行 + // ⚠️ **`Y=` 前缀画在输入框**内部(非编辑态),编辑态只显示公式本身。 + // 因此这里不写 `label`(空 label = 输入框占满整行,宽度与下方「系数调整」完全一致), + // 而是在值文本前拼上前缀;进入编辑时 `PopupMenu` 会改用 `editValue`(无前缀)。 + formulaEdit.label.clear(); formulaEdit.id = CMD_FORMULA_EDIT; - formulaEdit.value = formulaText; + formulaEdit.value = FormulaDisplayText(style->formula); formulaEdit.editValue = formulaText; formulaEdit.filter = EditFilter::ANY; items.push_back(formulaEdit); @@ -359,10 +362,14 @@ namespace jdi { case CMD_FORMULA_EDIT: { - // 「公式映射」:把自变量写法统一为 `f(t)`,非法表达式**不写入**并给出警告 - //(警告走自绘浮层,见 `TakePendingHint`;不使用 Windows 消息框)。 + // 「公式映射」:把自变量写法统一为 `f(t)`、常量统一为小写, + // 非法表达式**不写入**并给出警告(警告走自绘浮层,见 `TakePendingHint`; + // 不使用 Windows 消息框)。 + // ⚠️ **等效于 `f(t)` 的公式按“未启用”处理**(清空):它逐点恒等, + // 但却会让 `HasFormula()` 为真 ⇒ 关闭粗粒度包络层、逼得自适应量程 + // 每帧逐点扫描整条信号(千万级采样 ⇒ 界面完全卡死,用户 2026-11-03 报告)。 const std::wstring normalized = NormalizeFormulaText(trimmed); - if (normalized.empty()) + if (normalized.empty() || FormulaIsIdentity(normalized)) { style->formula.clear(); } @@ -380,9 +387,8 @@ namespace jdi } if (m_menu != nullptr) { - const std::wstring display = - style->formula.empty() ? std::wstring(L"f(t)") : style->formula; - m_menu->UpdateItemValue(CMD_FORMULA_EDIT, display, display); + m_menu->UpdateItemValue(CMD_FORMULA_EDIT, FormulaDisplayText(style->formula), + FormulaEditText(style->formula)); } return false; } @@ -448,7 +454,8 @@ namespace jdi style->formula.clear(); if (m_menu != nullptr) { - m_menu->UpdateItemValue(CMD_FORMULA_EDIT, L"f(t)", L"f(t)"); + m_menu->UpdateItemValue(CMD_FORMULA_EDIT, FormulaDisplayText(style->formula), + FormulaEditText(style->formula)); } } return false; diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index ef87f93..b74ce95 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -9555,3 +9555,107 @@ make release / make debug 零 warning;Release 814,080 B ⚠️ 探针里必须先 `Tab().SetVisible({0,0}, true)` 再开面板:新加载信号默认不勾选, 否则 `SetLaneResample` 找不到"第一个可见信号"、面板布局为空。 +--- + +## 2026-11-03 第二十五轮(本机,`dev` 分支):公式映射卡顿根治 + 输入框/语法完善 + 重采样面板三处修正 + +> 用户报告的六组问题(`VERY_BIG_DATA.CSV` 全部信号合并到同一网格): +> ①「映射/偏移」二级面板里**只是点进过**公式输入框,整个应用就变卡;编辑过公式后彻底没法用; +> ② `Y=` 应画在输入框**内部**、输入框与下方「系数」等宽、只允许 `X/x/f(t)` 指代数据点、 +> 需要 `^` 幂与常量 `e`/`pi`;③「重采样」面板点进间隔/频率输入框后**输入框整个消失**; +> ④ 最大/最小数据间隔出现科学计数法、且末尾要追加「(频率)」尾缀; +> ⑤ 重采样间隔显示要带自适应单位、编辑态用秒且不带单位;⑥ 频率编辑态用 Hz、显示态自适应单位。 + +### 1. ⭐⭐⭐ 卡顿真因:**恒等公式 `f(t)` 被当成"启用了公式"** + +`PopupMenu` 的内联编辑在**提交时回写当前文本**(即使一个字都没改):点进「公式映射」输入框、 +再点别处 ⇒ `EndItemEdit` 用初值 `f(t)` 提交 ⇒ 样式里存进 `formula = L"f(t)"`。 +而 `HasFormula()` 只判 `!formula.empty()` ⇒ 于是: + +* `DrawTransformOrderPreserving()` 返回 false ⇒ 丢掉粗粒度包络层(`coarseMin/Max`); +* `TabConfig::ComputeLaneRange()` 走「公式不保序 ⇒ 逐点扫描整条信号」分支 —— + 而 `BuildLaneVisuals()` **每帧**为每个网格调一次 ⇒ 2580 万采样约 **960ms/帧**。 + +修法(三层): +1. `FormulaIsIdentity()`(`DataModel.hpp`):空串与 `f(t)` **等价**,`HasFormula()` 两者都返回 false; +2. `SignalPanel::OnMenuCommand(CMD_FORMULA_EDIT)` 提交时,归一化后若恒等即 `formula.clear()`; +3. `PlotView::BuildLaneVisuals()` 对「网格幅值范围」加**指纹缓存**(`m_laneRangeKey` + + `m_laneRangeYMin/Max`):键 = 可见信号集合 + 影响数值的样式字段(含公式文本)+ 数据指针/长度/ + min/max/finiteCount。范围与视界无关,键不变就直接复用(实测 `BuildLaneVisuals` 0.0005ms)。 + +### 2. ⭐⭐ 真实公式仍然贵:**变换后的逐分组包络缓存** + +即便公式不是恒等,只要有公式就仍然退化成逐点扫描(一次折线构建 2580 万采样 ≈ **850ms**, +缩放一帧就卡一次)。新增 `PlotView::AcquireFormulaEnvelope()`: + +* 首次需要时**全量扫描一次**,把 `ApplyDrawTransform` 之后的取值按分组(`kFormulaEnvelopeBuckets` + = 65536 组,2580 万采样 ⇒ 每组 ~394 个采样)存 min/max(无有效值的组为 NaN); +* 之后 `BuildPolyline` 的包络带路径**改用它**(`coarseScale = 1, coarseOffset = 0`, + 变换已烘进包络)⇒ 每帧 / 每次缩放只按分组聚合; +* 极值仍是**逐点统计**的 ⇒ 不会漏掉尖峰(与"抽样降采样"有本质区别); +* 键 = 信号指针 + `values.data()` + 采样数 + 公式文本 + 系数/绝对值/镜像/Y 偏移(变化即失效)。 + +实测(`VERY_BIG_DATA` 合并网格 + `f(t)^2`): +静止帧 **215ms → 20.9ms**(单帧 5~6ms)、中键拖动 8.0ms、**缩放最坏帧 843ms → 81ms**。 + +### 3. ⚠️⚠️ 公式编译缓存的隐蔽错误:只比 `data()` 指针 + +`AcquireProgram()` 原来先比 `data()` 指针 + 长度就复用程序。而 `std::wstring` 的短串走 **SSO** +(缓冲区在对象内部)⇒ 把同一个样式对象的公式改成**等长**的另一条时指针与长度都不变 +⇒ 一直复用旧程序(`f(t)^2` 改成 `f(t)^3` 后波形不更新,用户完全看不出公式失效)。 +现在**必须比内容**(先比长度、再比内容;短公式只是十几字节的 memcmp)。 + +### 4. 公式语法(`Utilities/Inc|Src/FormulaEval`) + +* 新增 `^` 幂:`power := primary ('^' unary)?`,**右结合**(`2^3^2 = 512`)、 + `2^-1 = 0.5`、`-2^2 = -4`(一元负号作用于整个幂);`f(t)^2` 与 `2^f(t)` 都支持; +* 括号优先级沿用(仅小括号); +* 常量:`e`(自然常数)与 `pi`,**大小写都接受**(`E` / `Pi` / `PI`),保存前统一成小写; +* 数据点**只允许** `X` / `x` / `f(t)`:**单独的 `f` 不再接受**(`ValidateFormula` 报错, + `NormalizeFormulaText` **不**把它改写成 `f(t)`,避免"悄悄接受");`t` / `F^2` 等同样拒绝; +* 数字扫描修正:`2e` 里的 `e` 不再被当作指数吞掉(指数必须真的带数字),于是 `2e` 会按 + "数字 2 + 常量 e"解析失败(不支持隐式乘法)而给出明确报错。 + +### 5. 「公式映射」输入框(`SignalPanelMenu.cpp`) + +* 非编辑态文本 = `Y=` + 公式(画在**输入框内部**);进入编辑用 `editValue`(**无前缀**); +* `label` 留空 ⇒ 输入框占满整行,与下方「系数调整」**同宽**(`EditFieldRect` 的唯一宽度来源); +* 提交后统一回写 `FormulaDisplayText()` / `FormulaEditText()`(新增于 `DataModel.hpp`)。 + +### 6. 「重采样」面板三处修正 + +1. ⭐ **输入框消失**:面板绘制分支在编辑态会跳过输入框本体(改由 `PaintEditField` 画), + 但 `paintOverlays` 调用 `PaintEditField` 的名单里**漏了 `RESAMPLE_INTERVAL/FREQUENCY`** + ⇒ 双击后输入框整块不见。已补上。 +2. **最大/最小数据间隔**:改用 `FormatIntervalText()`(先 `RoundIntervalForDisplay()` 到 **3 位有效数字**, + 再按 Δt 语义的自适应复合单位),末尾追加 `(<频率>)` 尾缀(`FormatFrequencyText`,Hz/kHz/MHz/GHz 自适应); + 频率必须由**同一份舍入后的间隔**换算,否则两列对不上。 +3. **两个输入框**:非编辑态显示自适应单位(间隔 = `FormatIntervalText`,频率 = `FormatFrequencyText`); + 编辑态间隔 = **秒**、频率 = **Hz**,纯小数、**不带单位**(`FormatDurationSeconds` / `FormatDecimal`)。 + 面板宽度按「含(频率)尾缀」的最长文本量宽,避免尾缀被裁。 + +### 7. 十进制格式化(`Utf8Util`) + +新增 `FormatDecimal(value, significantDigits = 6)`:**绝不产生科学计数法**, +小数位数按"保留 N 位有效数字"自适应并去掉尾随 0。`FormatDurationCompound`(含 Δt 显示)、 +`FormatDurationSeconds`、`FormatFrequency` 全部改用它与它同源 ⇒ 间隔/频率读数不再出现 `1e-05`。 + +### 8. 验证(`.preflight/perf/probe/formulacheck_main.cpp`,离屏 `SW_HIDE`)**71/71** + +``` +A 语法:2^f(t) / f(t)^2 / (f(t)+1)^2 / -f(t)^2 / 2^3^2 / e / pi / E / PI 全部通过; + 拒绝 f、f^2、t、"f(t)+"、2(3)、f(t)^;求值 2^3^2=512、-2^2=-4、2^-1=0.5 +B 归一化与恒等:X*2→f(t)*2、F(T)^2→f(t)^2、E+Pi→e+pi、1E5→1e5、单独 f 不改写; + HasFormula(): 空 / f(t) → false,f(t)^2 → true;Y= 前缀只在显示态 +C 格式化:1e-5 秒 → 0.00001;5.06s → 5s 60ms;25641Hz → 25.641 kHz;1.5e6 → 1.5 MHz; + 均不含指数记号 +D 性能:VERY_BIG_DATA 合并网格 + f(t)^2 —— 静止 5~6ms(对无公式基线 6ms)、 + 中键拖动最坏 8.0ms、缩放最坏 81ms +E 面板:四行几何存在;最大间隔行文本(含(频率)尾缀)宽度 140 ≤ 266.5 可用宽度; + 进入「重采样间隔」编辑后输入框**确实画出**(该区域 9074 像素变化, + 且 `EditFieldRect()` 宽度 = 面板字段宽度 266.5) +``` + +`make release` / `make debug` 零 warning;产物 **856,064 B**(< 1MB 红线)。 + + diff --git a/Utilities/Inc/DataModel.hpp b/Utilities/Inc/DataModel.hpp index bf1447f..076b6ee 100644 --- a/Utilities/Inc/DataModel.hpp +++ b/Utilities/Inc/DataModel.hpp @@ -194,6 +194,50 @@ namespace jdi bool operator!=(const SignalRef& other) const { return !(*this == other); } }; + /** + * @brief 「公式映射」的**恒等**写法:`f(t)`(= 原样输出采样值)。 + * + * 空串与它语义完全相同(都表示“不做映射”)。 + */ + inline const wchar_t* const kIdentityFormulaText = L"f(t)"; + + /** + * @brief 判断公式文本是否**等价于不映射**(空串或 `f(t)`)。 + * + * ⚠️ 恒等公式会被当作“未启用”:它逐点恒等,但若被当成“有公式”, + * `DrawTransformOrderPreserving()` 就会返回 false(丢掉粗粒度包络层), + * 且自适应幅值范围会退化成**逐点扫描整条信号** —— 千万级采样时每帧几百毫秒 + * (用户 2026-11-03 报告的“只是点进过公式输入框就卡死”的直接原因)。 + * + * @param [IN] formula 公式文本。 + * @return 恒等(不做映射)返回 true。 + */ + inline bool FormulaIsIdentity(const std::wstring& formula) + { + return formula.empty() || formula == kIdentityFormulaText; + } + + /** + * @brief 公式输入框的**非编辑态**文本(内含 `Y=` 前缀,用户 2026-11-03 要求)。 + * @param [IN] formula 公式文本(空 = 未启用)。 + * @return 形如 `Y=f(t)` / `Y=f(t)^2 + 1` 的文本。 + */ + inline std::wstring FormulaDisplayText(const std::wstring& formula) + { + return std::wstring(L"Y=") + + (FormulaIsIdentity(formula) ? std::wstring(kIdentityFormulaText) : formula); + } + + /** + * @brief 公式输入框的**编辑态**文本(只有公式本身,不带 `Y=` 前缀)。 + * @param [IN] formula 公式文本(空 = 未启用)。 + * @return 形如 `f(t)` / `f(t)^2 + 1` 的文本。 + */ + inline std::wstring FormulaEditText(const std::wstring& formula) + { + return FormulaIsIdentity(formula) ? std::wstring(kIdentityFormulaText) : formula; + } + /** * @brief 单个信号的显示外观(颜色/透明度/线宽/绘制方式/数值变换)。 * @@ -237,11 +281,16 @@ namespace jdi * 结算顺序为 **公式映射 → 系数 → 绝对值 → 垂直镜像 → Y 偏移 → 时间轴偏移**, * 见 `ApplyDrawTransform`。表达式由 `Utilities/FormulaEval` 编译(带缓存), * 非法表达式的取值原样返回采样值(不会让波形消失)。 + * + * ⚠️ **`f(t)`(恒等映射)与空串等价**:`HasFormula()` 两者都返回 false。 + * 否则用户只是“点进输入框又点出去”就会让样式带上一个恒等公式, + * 而公式会关闭粗粒度包络层、逼得自适应量程每帧逐点扫描整条信号 + * (千万级采样时界面直接卡死,用户 2026-11-03 报告)。 */ std::wstring formula; - /** @brief 是否启用了「公式映射」。 */ - bool HasFormula() const { return !formula.empty(); } + /** @brief 是否**真的**启用了「公式映射」(`f(t)` 恒等映射不算)。 */ + bool HasFormula() const { return !FormulaIsIdentity(formula); } /** @brief 镜像功能的任一分量是否启用(决定「镜像」按钮的激活态)。 */ bool Mirrored() const { return mirrorVertical || mirrorHorizontal; } diff --git a/Utilities/Inc/FormulaEval.hpp b/Utilities/Inc/FormulaEval.hpp index f890cd9..a44281f 100644 --- a/Utilities/Inc/FormulaEval.hpp +++ b/Utilities/Inc/FormulaEval.hpp @@ -6,11 +6,12 @@ // 语法(刻意保持极小,避免引入脚本引擎与额外的体积): // expr := term (('+' | '-') term)* // term := unary (('*' | '/') unary)* -// unary := ('+' | '-') unary | primary -// primary := number | '(' expr ')' | variable +// unary := ('+' | '-') unary | power +// power := primary ('^' unary)? —— 右结合:2^3^2 = 2^(3^2) +// primary := number | '(' expr ')' | variable | constant // number := 十进制小数(支持 e/E 指数) -// variable:= `X` / `x` / `f` / `f(t)` —— 都表示"当前采样点的值" -// 另外支持常量 `pi`。 +// constant:= `e`(自然常数)| `pi`(圆周率)—— 大小写均接受,保存前统一为小写 +// variable:= `X` / `x` / `f(t)` —— 都表示"当前采样点的值"(**不支持单独的 `f`**) // // ⚠️ 求值走 `thread_local` 的单条缓存(按 `std::wstring` 的 data 指针 + 内容判定): // 绘制循环里同一条信号的样式对象是稳定的,因此每个信号只需编译一次; @@ -44,10 +45,14 @@ namespace jdi double EvaluateFormula(const std::wstring& text, double x); /** - * @brief 把用户输入里的自变量写法统一成 `f(t)`(保存前调用)。 + * @brief 把用户输入里的自变量写法统一成 `f(t)`、常量统一为小写(保存前调用)。 * - * 规则:把独立的 `X` / `x` / `f` 记号改写为 `f(t)`;`f(t)` 原样保留。 - * 只做记号级替换(不会动标识符中间或数字里的字符)。 + * 规则: + * * 把独立的 `X` / `x` / `f(t)`(含 `F(T)`)记号改写为 `f(t)`; + * * 把 `E` / `Pi` / `PI` 等写法改写为小写 `e` / `pi`; + * * **单独的 `f` 不改写**(它不是合法写法,留给校验报错)。 + * + * 只做记号级替换(不会动标识符中间或数字里的字符,如 `1e5` 保持不变)。 * * @param [IN] text 用户输入的公式。 * @return 归一化后的文本。 diff --git a/Utilities/Inc/Utf8Util.hpp b/Utilities/Inc/Utf8Util.hpp index 07c26f6..226c7af 100644 --- a/Utilities/Inc/Utf8Util.hpp +++ b/Utilities/Inc/Utf8Util.hpp @@ -82,6 +82,22 @@ namespace jdi */ std::wstring FormatValue(double value); + /** + * @brief 以**十进制小数**形式格式化数值(**绝不出现科学计数法**)。 + * + * 与 `FormatValue`(`%.6g`)的区别: + * * `FormatValue(1.0e-5)` → `"1e-05"`(科学计数法,不适合显示时间间隔 / 频率); + * * `FormatDecimal(1.0e-5)` → `"0.00001"`。 + * + * 小数位数按“保留 `significantDigits` 位有效数字”自适应(整数部分超过有效位时取 0 位), + * 末尾多余的 0 会被去掉;`-0` 归一成 `0`。 + * + * @param [IN] value 待格式化数值。 + * @param [IN] significantDigits 期望保留的有效数字位数(1–15,默认 6)。 + * @return 形如 `0.00001` / `25.641` / `1000000` 的文本。 + */ + std::wstring FormatDecimal(double value, int significantDigits = 6); + /** * @brief 依据坐标轴刻度步长格式化刻度标签。 * @param [IN] value 刻度值。 diff --git a/Utilities/Src/FormulaEval.cpp b/Utilities/Src/FormulaEval.cpp index c554e70..4ff0e3e 100644 --- a/Utilities/Src/FormulaEval.cpp +++ b/Utilities/Src/FormulaEval.cpp @@ -29,6 +29,7 @@ namespace jdi SUBTRACT, MULTIPLY, DIVIDE, + POWER, /**< 幂(`a ^ b`)。 */ NEGATE }; @@ -48,6 +49,7 @@ namespace jdi MINUS, STAR, SLASH, + CARET, LEFT_PAREN, RIGHT_PAREN, END @@ -101,6 +103,11 @@ namespace jdi tokens.push_back(Token{TokenKind::SLASH, 0.0}); ++index; } + else if (ch == L'^') + { + tokens.push_back(Token{TokenKind::CARET, 0.0}); + ++index; + } else if (ch == L'(') { tokens.push_back(Token{TokenKind::LEFT_PAREN, 0.0}); @@ -120,16 +127,22 @@ namespace jdi { ++index; } + // 指数部分必须**真的带数字**才算数字的一部分: + // `2e` 里的 `e` 是自然常数(不能把 `e` 吞进食指后当成数字 2 再用掉它)。 if (index < length && (text[index] == L'e' || text[index] == L'E')) { - ++index; - if (index < length && (text[index] == L'+' || text[index] == L'-')) + std::size_t probe = index + 1; + if (probe < length && (text[probe] == L'+' || text[probe] == L'-')) { - ++index; + ++probe; } - while (index < length && text[index] >= L'0' && text[index] <= L'9') + if (probe < length && text[probe] >= L'0' && text[probe] <= L'9') { - ++index; + index = probe; + while (index < length && text[index] >= L'0' && text[index] <= L'9') + { + ++index; + } } } const std::wstring literal = text.substr(start, index - start); @@ -147,7 +160,7 @@ namespace jdi } else if (std::iswalpha(static_cast(ch)) != 0) { - // 标识符:只允许 X / x / f / f(t) / pi。 + // 标识符:只允许 X / x / f(t) 作为数据点,以及常量 e / pi。 const std::size_t start = index; while (index < length && std::iswalpha(static_cast(text[index])) != 0) { @@ -160,6 +173,7 @@ namespace jdi { ++look; } + bool hasArgument = false; if (look < length && text[look] == L'(') { std::size_t inner = look + 1; @@ -176,6 +190,7 @@ namespace jdi } if (inner < length && text[inner] == L')') { + hasArgument = true; index = inner + 1; } } @@ -184,7 +199,7 @@ namespace jdi { letter = static_cast(std::towlower(static_cast(letter))); } - if (name == L"x" || name == L"f") + if (name == L"x" || (name == L"f" && hasArgument)) { tokens.push_back(Token{TokenKind::VARIABLE, 0.0}); } @@ -192,11 +207,15 @@ namespace jdi { tokens.push_back(Token{TokenKind::NUMBER, 3.14159265358979323846}); } + else if (name == L"e") + { + tokens.push_back(Token{TokenKind::NUMBER, 2.71828182845904523536}); + } else { if (error != nullptr) { - *error = L"无法识别的标识符(自变量请写 f(t) 或 X)。"; + *error = L"无法识别的标识符(数据点请写 f(t) 或 X;常量只支持 e / pi)。"; } return false; } @@ -321,7 +340,34 @@ namespace jdi m_output.push_back(FormulaOp{FormulaOp::Kind::NEGATE, 0.0}); return true; } - return ParsePrimary(); + return ParsePower(); + } + + /** + * @brief 幂运算(优先级高于乘除、低于一元正负号;**右结合**)。 + * + * `power := primary ('^' unary)?` + * * `2^3^2` = 2^(3^2)(右结合); + * * `2^-1` 允许(指数的符号); + * * `-2^2` = -(2^2)(一元负号作用于整个幂)。 + */ + bool ParsePower() + { + if (!ParsePrimary()) + { + return false; + } + if (Current().kind != TokenKind::CARET) + { + return true; + } + Advance(); + if (!ParseUnary()) + { + return false; + } + m_output.push_back(FormulaOp{FormulaOp::Kind::POWER, 0.0}); + return true; } bool ParsePrimary() @@ -365,10 +411,9 @@ namespace jdi /** 编译结果(逆波兰式);空表示"不可用"。 */ using Program = std::vector; - /** 线程内的单条编译缓存(按 data 指针 + 内容判定)。 */ + /** 线程内的单条编译缓存(按内容判定)。 */ struct FormulaCache { - const wchar_t* data = nullptr; std::size_t size = 0; std::wstring text; Program program; @@ -387,19 +432,16 @@ namespace jdi return nullptr; } static thread_local FormulaCache cache; - if (cache.data == text.data() && cache.size == text.size() && cache.compiled) + // ⚠️⚠️ **必须比较内容,不能只比 `data()` 指针 + 长度**:`std::wstring` 的短串走 + // **SSO**(缓冲区就在对象内部),把同一个样式对象的公式改成**等长**的另一条时, + // `data()` 与 `size()` 都不会变 ⇒ 只比指针会一直复用**旧程序** + //(实测:`f(t)^2` 改成 `f(t)^3` 后波形不更新,用户无法察觉公式已失效)。 + // 比较顺序:先比长度(最便宜),再比内容(短公式只是十几字节的 memcmp)。 + if (cache.compiled && cache.size == text.size() && cache.text == text) { 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; @@ -498,6 +540,9 @@ namespace jdi case FormulaOp::Kind::DIVIDE: result = a / b; break; + case FormulaOp::Kind::POWER: + result = std::pow(a, b); + break; default: break; } @@ -567,14 +612,21 @@ namespace jdi { letter = static_cast(std::towlower(static_cast(letter))); } - if (hasArgument && (lowered == L"f" || lowered == L"x")) + // 数据点:`X` / `x` / `f(t)`(含 `F(T)`)一律写成规范形式 `f(t)`。 + // ⚠️ **单独的 `f` 不归一化**:它不是合法写法(用户 2026-11-03 明确要求), + // 原样输出交给 `ValidateFormula` 报错,而不是悄悄当成 `f(t)` 接受。 + if (lowered == L"x" || (lowered == L"f" && hasArgument)) { - // 统一写成小写规范形式 `f(t)`(保留用户其余写法)。 output += L"f(t)"; } - else if (!hasArgument && (lowered == L"x" || lowered == L"f")) + // 常量统一为小写(`E` / `Pi` / `PI` → `e` / `pi`)。 + else if (lowered == L"e") { - output += L"f(t)"; + output += L"e"; + } + else if (lowered == L"pi") + { + output += L"pi"; } else { diff --git a/Utilities/Src/Utf8Util.cpp b/Utilities/Src/Utf8Util.cpp index 6c4ba53..5f272d2 100644 --- a/Utilities/Src/Utf8Util.cpp +++ b/Utilities/Src/Utf8Util.cpp @@ -346,6 +346,72 @@ namespace jdi return std::wstring(buffer); } + std::wstring FormatDecimal(double value, int significantDigits) + { + if (std::isnan(value)) + { + return L"NaN"; + } + if (std::isinf(value)) + { + return (value > 0.0) ? L"+Inf" : L"-Inf"; + } + if (significantDigits < 1) + { + significantDigits = 1; + } + if (significantDigits > 15) + { + significantDigits = 15; + } + const double magnitude = std::fabs(value); + if (magnitude == 0.0) + { + return L"0"; + } + + // 小数位数 = 让结果保留约 `significantDigits` 位有效数字(整数部分已超出时取 0 位)。 + // ⚠️ **绝不用 `%g` / `%e`**:`%g` 在量级小于 1e-4 或大于 1e6 时会切换成科学计数法, + // 对“时间间隔 / 频率”这类读数极不友好(用户 2026-11-03 明确要求小数形式)。 + int decimals = 0; + { + const double exponent = std::floor(std::log10(magnitude)); + decimals = significantDigits - 1 - static_cast(exponent); + } + if (decimals < 0) + { + decimals = 0; + } + if (decimals > 12) + { + decimals = 12; + } + + wchar_t buffer[160]; + swprintf(buffer, sizeof(buffer) / sizeof(buffer[0]), L"%.*f", decimals, value); + std::wstring text(buffer); + + // 去掉尾随的 0 与孤立的小数点(`1.50000` → `1.5`、`2.000000` → `2`)。 + if (text.find(L'.') != std::wstring::npos) + { + std::size_t last = text.find_last_not_of(L'0'); + if (last == std::wstring::npos) + { + return L"0"; + } + if (text[last] == L'.') + { + --last; + } + text.erase(last + 1); + } + if (text == L"-0") + { + text = L"0"; + } + return text; + } + std::wstring FormatDurationCompound(double seconds) { if (!std::isfinite(seconds)) @@ -406,12 +472,12 @@ namespace jdi { const double whole = std::floor(value); const double fraction = value - whole; - std::wstring text = FormatValue(whole) + L"s"; + std::wstring text = FormatDecimal(whole) + L"s"; const double remainderMs = fraction * 1000.0; if (remainderMs >= 1.0e-6) { text += L" "; - text += FormatValue(remainderMs); + text += FormatDecimal(remainderMs); text += L"ms"; } return text; @@ -435,7 +501,7 @@ namespace jdi // 顶级单位为 ps:不再递进次级单位(保留小数)。 if (index + 1 >= subCount) { - return FormatValue(value / subUnits[index].scale) + L" " + subUnits[index].name; + return FormatDecimal(value / subUnits[index].scale) + L" " + subUnits[index].name; } const double scaled = value / subUnits[index].scale; @@ -456,7 +522,7 @@ namespace jdi if (inNext >= 1.0e-6) { text += L" "; - text += FormatValue(inNext); + text += FormatDecimal(inNext); text += subUnits[index + 1].name; } return text; @@ -468,7 +534,8 @@ namespace jdi { return L"0"; } - return FormatValue(std::fabs(seconds)); + // ⚠️ 内联编辑的数值**不得出现科学计数法**(用户 2026-11-03 要求)。 + return FormatDecimal(std::fabs(seconds), 12); } std::wstring FormatFrequency(double hertz) @@ -494,7 +561,9 @@ namespace jdi unit = L"kHz"; scaled = hertz / 1.0e3; } - return FormatValue(scaled) + L" " + unit; + // ⚠️ 频率同样不得出现科学计数法(用户 2026-11-03 要求): + // `FormatValue` 的 `%.6g` 在 1e-5 Hz 这类量级会输出 “1e-06”,改为十进制形式。 + return FormatDecimal(scaled) + L" " + unit; } std::wstring FormatByteSize(unsigned long long bytes)