fix(formula,resample): 公式映射卡顿根治 + 语法/输入框完善 + 重采样面板三处修正

一、「公式映射」卡顿(用户报告:只是点进输入框就卡,编辑过公式后彻底没法用)
1. 真因:菜单内联编辑提交时无条件回写初值 ⇒ 点进输入框再点别处就把
   formula = "f(t)" 写进样式;而 HasFormula() 只判 !empty(),于是
   DrawTransformOrderPreserving() 变 false、ComputeLaneRange() 每帧逐点扫描
   整条信号(2580 万采样 ≈ 960ms/帧)。
   修法:新增 FormulaIsIdentity()(空串与 f(t) 等价);提交时归一化后恒等即清空;
   BuildLaneVisuals() 为「网格幅值范围」加指纹缓存(数据指针/长度/统计值 +
   影响数值的样式字段),键不变直接复用(实测 0.0005ms)。
2. 真实公式仍然贵:新增 PlotView::AcquireFormulaEnvelope()(变换后的逐分组
   包络,65536 组,按需算一次并缓存),BuildPolyline 的包络带改用它。
   实测:静止 215→21ms、中键拖动 8ms、缩放最坏 843→81ms;极值仍逐点统计
   (不会漏尖峰)。
3. AcquireProgram 只比 data() 指针 + 长度 ⇒ std::wstring 短串走 SSO 时,
   等长改公式会一直复用旧程序(f(t)^2→f(t)^3 波形不更新)。改为按内容判定。

二、公式语法(Utilities/FormulaEval)
- 新增 ^ 幂(右结合,2^3^2=512、2^-1=0.5、-2^2=-4),支持 2^f(t) 与 f(t)^2;
- 常量 e / pi(大小写均接受,保存前统一小写);
- 数据点只允许 X / x / f(t):单独的 f 不再接受(且不归一化,直接报错);
- 数字扫描修正:2e 里的 e 不再被当成指数吞掉。

三、「公式映射」输入框(SignalPanelMenu)
- Y= 画在输入框内部(非编辑态),进入编辑态只显示公式本身;
- label 留空 ⇒ 输入框占满整行,与下方「系数调整」同宽。

四、「重采样」面板
- 修复点进间隔/频率输入框后输入框整块消失(paintOverlays 的 PaintEditField
  名单漏了 RESAMPLE_INTERVAL / RESAMPLE_FREQUENCY);
- 最大/最小数据间隔:3 位有效数字 + Δt 复合单位,末尾追加(频率)尾缀;
- 间隔显示自适应单位、编辑态用秒;频率显示自适应单位、编辑态用 Hz(均不带单位);
- 面板宽度按含(频率)尾缀的文本量宽。

五、十进制格式化(Utf8Util)
- 新增 FormatDecimal(绝不产生科学计数法);FormatDurationCompound /
  FormatDurationSeconds / FormatFrequency 改用它,Δt/间隔/频率读数不再出现 1e-05。

验证:离屏探针 .preflight/perf/probe/formulacheck_main.cpp 75/75;
make release / make debug 零 warning;Release 856,064 B。
This commit is contained in:
DeepSeek-V4.1-Flash committed 2026-11-03 02:55:22 +08:00
1 parent 955b233e63
commit 74456b9305
13 files changed
+682 -74

No files matched your search

+51 -2
View File
@@ -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; }
+12 -7
View File
@@ -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 归一化后的文本。
+16
View File
@@ -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 刻度值。
+76 -24
View File
@@ -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<wint_t>(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<wint_t>(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<wchar_t>(std::towlower(static_cast<wint_t>(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<FormulaOp>;
/** 线程内的单条编译缓存(按 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<wchar_t>(std::towlower(static_cast<wint_t>(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
{
+75 -6
View File
@@ -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<int>(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)