Compare commits

..
3 Commits
Author SHA1 Message Date
DeepSeek-V4.1-Flash 74456b9305 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。
2026-11-03 02:55:22 +08:00
DeepSeek V4.1 Flash 955b233e63 feat(style): 「系数/偏移」改为「映射/偏移」并新增「公式映射」
- 按钮文案改为「映射/偏移」;激活态 = 公式非空 或 系数≠1 或 任一偏移≠0
- 二级面板顶部新增「公式映射」(标题栏带还原图标):Y = + 表达式输入框,默认 f(t)
- 结算顺序固定:公式映射 -> 系数 -> 绝对值 -> 垂直镜像 -> Y 偏移 -> 时间轴偏移
- 表达式规则:支持 + - * / ( ) 一元正负、常量 pi、自变量 X/x/f/f(t);保存前把 X 归一化为 f(t)
- 非法表达式不写入样式,警告走 SignalPanel::TakePendingHint -> PlotView::ShowPersistentHint(自绘浮层,不用 MessageBox)
- 公式参与折线缓存键 / SceneContentHash / SceneStyleEntry / HaloLayerCache;
  DrawTransformOrderPreserving 返回 false(非保序),自适应幅值在 HasFormula() 时逐点扫描
- 派生信号与克隆的烘焙自动包含公式(时间轴偏移除外)
- Documents/agent_mem.md 追加「第二十四轮」硬约束
2026-11-02 20:42:58 +08:00
DeepSeek V4.1 Flash 1e1c2432ae feat(resample): 实验性功能新增「重采样」(位于「自由标记」上方)
- Lane::ResampleItem + resample/resampleItems/resamplePanel/resampleScroll,
  并在 LaneCarry / SplitLane / PruneLaneSignalItems / RemapAfterSignalInsert|Removal 同步
- 面板:可滚动分组列表(信号色条目栏 + 复选框 + 展开三角),展开后 4 行:
  最大/最小数据间隔(只读,勾选时一次性缓存,绘制不扫描采样)+ 重采样间隔/频率(输入)
- 输入框联动:改间隔同步频率,改频率同步间隔(输入单位分别为秒 / Hz)
- 「构建为新信号」:DataSet::AppendGroupAxis 追加新时间轴 + InsertDerivedSignal,
  采样值先按公式/系数/绝对值/镜像/Y偏移变换再等间隔线性插值,时间轴偏移不烘值
- 新信号名 <原信号> (重采样 <自适应单位频率>);Utf8Util::FormatFrequency 为唯一实现
- 非单调轴 / 点太少 / 间隔过大 / 点数超 2000 万 ⇒ 拒绝构建并给出自绘提示
- 顺带加入 FormulaEval(公式编译/求值,供 ApplyDrawTransform 与后续 UI 使用)
2026-11-02 20:41:26 +08:00
23 changed files with 2718 additions and 66 deletions

No files matched your search

@@ -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 把一条信号插入到「源信号」的下方,并同步各标签页的引用与可见性。
*
@@ -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<int>(group->signals.size()))
{
return false;
}
if (!std::isfinite(interval) || interval <= 0.0)
{
return false;
}
const SignalData& source = group->signals[static_cast<std::size_t>(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<double> sourceTimes;
std::vector<double> 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<std::size_t>(stepsExact);
TimeAxis newAxis;
newAxis.name = axis.name;
newAxis.time.resize(targetCount);
newAxis.monotonic = true;
std::vector<double> 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<double>(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<PlotView::ResampleBuild> 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<DerivedSignalRequest> 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);
+144 -4
View File
@@ -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<ResampleBuild>& 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 设置某网格是否处于「自由标记点选取」(浮动面板底部的切换按钮)。
*
@@ -1729,6 +1794,47 @@ namespace jdi
std::vector<gd::PointF>& band, std::vector<std::size_t>& 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<double> minimums; /**< 每组的绘制值下限。 */
std::vector<double> 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 绘图上下文。
@@ -1902,6 +2008,17 @@ namespace jdi
*/
std::vector<gd::RectF> rowLevelResets;
std::vector<gd::RectF> rowIterResets;
/**
* @brief 「重采样」面板每个被勾选信号的四行内容(与 `rowRefs` 同序)。
*
* `rowInfoMaxInterval` / `rowInfoMinInterval` = 只读统计(最大 / 最小数据间隔);
* `rowIntervalField` / `rowFrequencyField` = 两行**联动输入框**(间隔秒 / 频率 Hz)。
* 未勾选或非 RESAMPLE 面板时对应元素为空矩形(数组也可能为空)。
*/
std::vector<gd::RectF> rowInfoMaxInterval;
std::vector<gd::RectF> rowInfoMinInterval;
std::vector<gd::RectF> rowIntervalField;
std::vector<gd::RectF> rowFrequencyField;
/**
* @brief 「均值滤波」面板每个信号的**信号色叠底标题栏**矩形(与 `rowRefs` 同序)。
*
@@ -2896,6 +3013,8 @@ namespace jdi
int m_pendingWeakPeakLane = -1; /**< 待构建的弱峰调整信号所属网格下标(-1 表示无请求)。 */
std::vector<MeanFilterBuild> m_pendingMeanFilterItems; /**< 待构建的信号及其参数。 */
std::vector<WeakPeakBuild> m_pendingWeakPeakItems; /**< 待构建的弱峰调整信号及其参数。 */
int m_pendingResampleLane = -1; /**< 待构建的重采样信号所属网格下标(-1 表示无请求)。 */
std::vector<ResampleBuild> m_pendingResampleItems; /**< 待构建的重采样信号及其间隔。 */
bool m_mouseOverPanel = false; /**< 上一帧鼠标是否落在某个实验性功能面板上。 */
int m_peakDragLane = -1; /**< 正在拖动弱峰滑块的面板所属网格。 */
SignalRef m_peakDragRef; /**< 正在拖动弱峰滑块的**信号**(每个信号一套参数)。 */
@@ -3105,6 +3224,18 @@ namespace jdi
std::vector<gd::RectF> m_cursorLabelRects; /**< 本次绘制已占用的取值标注矩形(用于避让重叠)。 */
std::vector<LaneVisual> m_lanes; /**< 当前网格绘制信息集合。 */
/**
* @brief 「网格幅值范围」缓存指纹(见 `BuildLaneVisuals`)。
*
* `ComputeLaneRange()` 对启用了「公式映射」的信号必须**逐点扫描整条信号**
*(公式不保证单调,端点映射会算错)。而它在 `BuildLaneVisuals` 里**每帧**为
* 每个网格各调一次 ⇒ 千万级采样时每帧几百毫秒。范围只依赖「可见信号 + 数值样式 +
* 数据本身」,与视界无关,因此按指纹缓存:指纹一致就复用 `m_laneRangeYMin/Max`。
*/
std::uint64_t m_laneRangeKey = 0;
std::vector<double> m_laneRangeYMin; /**< 各网格缓存的 yMin(与上述指纹配套)。 */
std::vector<double> m_laneRangeYMax; /**< 各网格缓存的 yMax(与上述指纹配套)。 */
/**
* @brief 「边沿统计」的累积状态(普通边沿计数与中心轴边沿计数**共用**)。
*
@@ -3441,6 +3572,13 @@ namespace jdi
const LaneVisual& lane) const;
mutable std::deque<PolylineCacheEntry> m_polylineCache; /**< 折线顶点缓存(按几何键复用)。 */
/**
* @brief 「公式映射」信号的逐分组包络缓存(见 `AcquireFormulaEnvelope`)。
*
* 只在信号带公式时建立;条目键含公式文本与影响数值的样式字段,
* 公式 / 系数 / 镜像等变化时自然失效。用 `deque` 保证追加时已有引用不失效。
*/
mutable std::deque<FormulaEnvelopeEntry> m_formulaEnvelopes;
/**
* @brief 折线顶点缓存的**容量上限**(条目数)。
*
@@ -3585,6 +3723,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 +3733,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 +3868,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<gd::Bitmap> bitmap; /**< 32bpp ARGB 光晕图层(直通 alpha)。 */
+86 -1
View File
@@ -587,6 +587,38 @@ 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;
// ---- 「公式映射」的分组包络缓存(见 `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`**:滑块的量程本身已经说明是数值,符号只会让标签变长。
@@ -1034,7 +1066,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<int>(sizeof(kEdgePanelKinds) / sizeof(kEdgePanelKinds[0]));
@@ -1274,6 +1306,59 @@ 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 …),
* 且先舍入到 3 位有效数字(见 `RoundIntervalForDisplay`),**不出现科学计数法**。
*
* @param [IN] seconds 时间间隔(秒)。
* @return 文本。
*/
inline std::wstring FormatIntervalText(double seconds)
{
if (!std::isfinite(seconds) || seconds <= 0.0)
{
return L"—";
}
return FormatDurationCompound(RoundIntervalForDisplay(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 颜色。
+90
View File
@@ -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<std::uintptr_t>(m_dataSet));
mixRange(static_cast<std::uint64_t>(count));
for (const Lane& lane : lanes)
{
mixRange(lane.autoRange ? 1ull : 0ull);
mixRangeDouble(lane.yMin);
mixRangeDouble(lane.yMax);
mixRange(static_cast<std::uint64_t>(lane.signals.size()));
for (const SignalRef& ref : lane.signals)
{
mixRange(static_cast<std::uint64_t>(ref.groupIndex + 2));
mixRange(static_cast<std::uint64_t>(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<std::uintptr_t>(signal));
mixRange(reinterpret_cast<std::uintptr_t>(style));
if (signal != nullptr)
{
mixRange(reinterpret_cast<std::uintptr_t>(signal->values.data()));
mixRange(static_cast<std::uint64_t>(signal->values.size()));
mixRange(static_cast<std::uint64_t>(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<std::uint64_t>(static_cast<std::uint32_t>(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<float>(index) * (laneHeight + gap) - scrollOffset,
m_plotRect.Width, laneHeight);
if (reuseRange)
{
visual.yMin = m_laneRangeYMin[index];
visual.yMax = m_laneRangeYMax[index];
}
else
{
m_tab->ComputeLaneRange(*m_dataSet, static_cast<int>(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
@@ -208,6 +208,8 @@ namespace jdi
// 偏移(Y 平移 + 时间平移)同样会改变波形。
mixFloat(resolved.style->offsetY);
mixFloat(static_cast<float>(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);
}
+187 -2
View File
@@ -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<int>(m_tab->Lanes().size()))
{
return false;
}
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(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<int>(m_tab->Lanes().size()))
{
return false;
}
return m_tab->Lanes()[static_cast<std::size_t>(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<int>(m_tab->Lanes().size()))
{
return false;
}
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(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<int>(m_tab->Lanes().size()))
{
return false;
}
return m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].ResampleEnabled(ref);
}
bool PlotView::SetLaneResampleScroll(int laneIndex, float scroll)
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(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<ResampleBuild>& 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<int>(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)):
// 当视界覆盖整条信号、且该信号没有任何数值变换时,逐点扫描的结果与
+182 -9
View File
@@ -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<std::size_t>(laneIndex)];
Lane::ResampleItem* existing = const_cast<Lane::ResampleItem*>(
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<EdgePanelKind>(panelKind) == EdgePanelKind::MEAN ||
static_cast<EdgePanelKind>(panelKind) == EdgePanelKind::PEAK) &&
static_cast<EdgePanelKind>(panelKind) == EdgePanelKind::PEAK ||
static_cast<EdgePanelKind>(panelKind) == EdgePanelKind::RESAMPLE) &&
layout.button.Width > 0.0f &&
UiPointInRect(layout.button, static_cast<float>(x), static_cast<float>(y)))
{
@@ -1080,7 +1108,8 @@ namespace jdi
const EdgePanelKind cursorKind = static_cast<EdgePanelKind>(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<std::size_t>(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<float>(x),
static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
}
if (rowIndex < layout.rowFrequencyField.size() &&
UiPointInRect(layout.rowFrequencyField[rowIndex], static_cast<float>(x),
static_cast<float>(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<EdgePanelKind>(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<float>(x), static_cast<float>(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,117 @@ 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,
FormatDurationSeconds(item->interval), field);
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,
FormatDecimal(hz, 12), field);
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<std::size_t>(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 +3437,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 +3457,8 @@ namespace jdi
? wheelLane.weakPeakScroll
: (hoveredKind == EdgePanelKind::COUNT)
? wheelLane.edgeCountScroll
: (hoveredKind == EdgePanelKind::RESAMPLE)
? wheelLane.resampleScroll
: wheelLane.meanFilterScroll;
const float next = current - static_cast<float>(notches * 40.0 * m_scale);
const float target = std::max(0.0f, std::min(next, maxScroll));
@@ -3303,6 +3473,9 @@ namespace jdi
case EdgePanelKind::COUNT:
SetLaneEdgeCountScroll(panelLane, target);
break;
case EdgePanelKind::RESAMPLE:
SetLaneResampleScroll(panelLane, target);
break;
default:
SetLaneMeanFilterScroll(panelLane, target);
break;
+293 -15
View File
@@ -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<std::uint64_t>(static_cast<std::uint32_t>(character));
textHash *= 1099511628211ull;
}
}
mixHash(textHash);
}
for (const PolylineCacheEntry& entry : m_polylineCache)
{
@@ -197,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<double>& 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<const void*>(resolved.data);
const void* valuesKey = static_cast<const void*>(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<std::size_t>(
1, (count + kFormulaEnvelopeBuckets - 1) / kFormulaEnvelopeBuckets);
const std::size_t buckets = (count + entry.stride - 1) / entry.stride;
entry.minimums.assign(buckets, std::numeric_limits<double>::quiet_NaN());
entry.maximums.assign(buckets, std::numeric_limits<double>::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<gd::PointF>& points, std::vector<std::size_t>& breaks,
std::vector<gd::PointF>& band, std::vector<std::size_t>& bandBreaks,
@@ -343,19 +434,38 @@ namespace jdi
// 粗粒度包络层(若已构建):每列只需读取覆盖到的分组,
// 数据量从“可见采样数”降到“像素列数 × 少量分组”,
// 且分组内存的就是真实极值,绘制结果与逐点扫描完全一致。
const std::vector<double>& coarseMin = resolved.data->coarseMin;
const std::vector<double>& coarseMax = resolved.data->coarseMax;
const std::size_t coarseStride = resolved.data->coarseStride;
const std::vector<double>* coarseMinPtr = &resolved.data->coarseMin;
const std::vector<double>* 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()) &&
bool hasCoarse = (coarseStride > 0) && !coarseMinPtr->empty() &&
(coarseMinPtr->size() == coarseMaxPtr->size()) &&
DrawTransformOrderPreserving(*resolved.style);
// 保序变换(正系数 + Y 偏移)下:分组极值 × 系数 + 偏移 即为变换后的极值
//(系数 1、偏移 0 时逐位不变)。
const double coarseScale = static_cast<double>(resolved.style->gain);
const double coarseOffset = static_cast<double>(resolved.style->offsetY);
double coarseScale = static_cast<double>(resolved.style->gain);
double coarseOffset = static_cast<double>(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<double>& coarseMin = *coarseMinPtr;
const std::vector<double>& coarseMax = *coarseMaxPtr;
bool inSegment = false;
@@ -959,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);
}
@@ -4071,6 +4185,16 @@ namespace jdi
mix(static_cast<std::uint64_t>(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 +4248,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 +4882,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 +5086,10 @@ namespace jdi
meanFilter ? std::wstring(L"均值滤波")
: (weakPeak ? std::wstring(kWeakPeakTitle)
: (freeMarker ? std::wstring(L"自由标记")
: (resamplePanel
? std::wstring(kResampleTitle)
: ((centerAxis ? L"中心轴边沿计数" : L"边沿计数") +
EdgeRangeSuffix())));
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 +5314,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 +5345,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 +5397,119 @@ 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;
// 「最大 / 最小数据间隔」= 自适应单位的**小数**文本(绝不用科学计数法),
// 末尾追加一个括号内的「对应频率」尾缀(单位在 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 ? intervalWithFrequency(maxInterval) : L"—");
}
if (rowIndex < layout.rowInfoMinInterval.size())
{
paintInfoRow(layout.rowInfoMinInterval[rowIndex], kResampleMinIntervalLabel,
hasInterval ? intervalWithFrequency(minInterval) : L"—");
}
const double interval = (resampleItem->interval > 0.0)
? resampleItem->interval
: minInterval;
const bool editingInterval =
(m_editTarget == PlotEditTarget::RESAMPLE_INTERVAL) &&
(m_editLane == static_cast<int>(index)) && (m_editMeanRef == ref);
const bool editingFrequency =
(m_editTarget == PlotEditTarget::RESAMPLE_FREQUENCY) &&
(m_editLane == static_cast<int>(index)) && (m_editMeanRef == ref);
if (rowIndex < layout.rowIntervalField.size())
{
// 非编辑态:自适应单位 + 小数(与「最大/最小数据间隔」同一套单位形式)。
// 编辑态:只显示**秒**数值(不带单位),由 `PaintEditField` 绘制。
paintInputRow(layout.rowIntervalField[rowIndex], kResampleIntervalLabel,
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,
(hertz > 0.0) ? FormatFrequencyText(hertz) : L"—",
editingFrequency);
}
continue;
}
// 「中心轴边沿计数」:参数行最上面是**同一行**的「上升沿 / 下降沿」统计。
if (centerAxis && rowIndex < layout.rowStats.size() &&
layout.rowStats[rowIndex].Width > 0.0f)
+124 -21
View File
@@ -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,25 @@ 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)
{
// 「重采样」四行 = 「标签列 + 数值列」(两行统计 + 两行输入框),
// 标签取四者中最宽的一个,数值列按间隔 / 频率的典型文本宽度取。
// ⚠️ 「最大/最小数据间隔」行末尾还带「(频率)」尾缀(用户 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(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);
}
}
if (weakPeak)
@@ -174,9 +197,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 +299,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
: (resamplePanel ? lane.resample
: (centerAxis ? lane.edgeCenter
: lane.edgeCount)));
: 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 +327,11 @@ namespace jdi
const bool collapsed = meanFilter ? lane.meanFilterPanel.collapsed
: (weakPeak ? lane.weakPeakPanel.collapsed
: (freeMarker ? lane.freeMarkerPanel.collapsed
: (resamplePanel
? lane.resamplePanel.collapsed
: (centerAxis
? lane.edgeCenterPanel.collapsed
: lane.edgePanel.collapsed)));
: lane.edgePanel.collapsed))));
layout.collapsed = collapsed;
int rowCount = 0;
@@ -368,7 +398,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);
@@ -384,6 +419,13 @@ namespace jdi
expanded = (item != nullptr && !item->collapsed);
}
if (expanded)
{
if (resamplePanel)
{
// 四行:最大间隔 / 最小间隔 / 重采样间隔 / 重采样频率。
content += sliderRow * 4.0f;
}
else
{
content += sliderRow * 2.0f;
if (centerAxis)
@@ -392,6 +434,7 @@ namespace jdi
}
}
}
}
meanContentHeight = content;
const float budget =
m_plotRect.Height - (titleHeight + pad + bottomPad + footerHeight);
@@ -489,13 +532,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 +568,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,
static const EdgePanelKind kOrder[6] = {EdgePanelKind::COUNT, EdgePanelKind::CENTER,
EdgePanelKind::MEAN, EdgePanelKind::PEAK,
EdgePanelKind::MARK};
EdgePanelKind::MARK, EdgePanelKind::RESAMPLE};
int openSeen = 0;
bool found = false;
for (std::size_t lane = 0; lane < m_lanes.size() && !found; ++lane)
@@ -701,7 +752,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 +787,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 +812,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 +864,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;
}
// 「中心轴边沿计数」:参数行最上面是**同一行**的「上升沿 / 下降沿」统计。
+13 -1
View File
@@ -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<float>(resolved.style->offsetTime));
// 「公式映射」逐点改变图层里的波形 ⇒ 必须让图层失效。
{
std::uint64_t textHash = 1469598103934665603ull;
for (const wchar_t character : resolved.style->formula)
{
textHash ^= static_cast<std::uint64_t>(static_cast<std::uint32_t>(character));
textHash *= 1099511628211ull;
}
mix(textHash);
}
}
return hash;
}
+27 -1
View File
@@ -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 待确认的「关闭类」按钮(✕)。
*
+73 -3
View File
@@ -20,12 +20,35 @@ namespace jdi
return false;
}
// 二级面板:两张“标题(带还原图标)+ 输入框”的组合:
// 1. 「系数调整」:一整行系数输入框;
// 2. 「偏移调整」:下方两行分别为「Y轴」与「时间轴」的偏移输入框。
// 二级面板:三组“标题(带还原图标)+ 输入框”的组合:
// 1. 「公式映射」:`Y =` + 表达式输入框(自变量写作 f(t));
// 2. 「系数调整」:一整行系数输入框;
// 3. 「偏移调整」:下方两行分别为「Y轴」与「时间轴」的偏移输入框。
//(与网格 Y 轴上下限的菜单内联编辑同一套做法:提交后面板保持打开,可连续微调。)
// ⚠️ 结算顺序固定为「公式映射 → 系数 → 偏移」(用户 2026-11-02 明确要求)。
std::vector<MenuItem> items;
// 「公式映射」:默认 `f(t)`(= 等值照搬)。
MenuItem formulaHeader;
formulaHeader.kind = MenuItemKind::HEADER_ACTION;
formulaHeader.label = L"公式映射";
formulaHeader.id = CMD_FORMULA_RESET; // 点击还原图标 → 恢复为 f(t)
items.push_back(formulaHeader);
const std::wstring formulaText =
style->formula.empty() ? std::wstring(L"f(t)") : style->formula;
MenuItem formulaEdit;
formulaEdit.kind = MenuItemKind::EDIT;
// ⚠️ **`Y=` 前缀画在输入框**内部(非编辑态),编辑态只显示公式本身。
// 因此这里不写 `label`(空 label = 输入框占满整行,宽度与下方「系数调整」完全一致),
// 而是在值文本前拼上前缀;进入编辑时 `PopupMenu` 会改用 `editValue`(无前缀)。
formulaEdit.label.clear();
formulaEdit.id = CMD_FORMULA_EDIT;
formulaEdit.value = FormulaDisplayText(style->formula);
formulaEdit.editValue = formulaText;
formulaEdit.filter = EditFilter::ANY;
items.push_back(formulaEdit);
MenuItem gainHeader;
gainHeader.kind = MenuItemKind::HEADER_ACTION;
gainHeader.label = L"系数调整";
@@ -337,6 +360,38 @@ namespace jdi
const bool valid = !trimmed.empty() && stop != trimmed.c_str() && std::isfinite(parsed);
switch (result.id)
{
case CMD_FORMULA_EDIT:
{
// 「公式映射」:把自变量写法统一为 `f(t)`、常量统一为小写,
// 非法表达式**不写入**并给出警告(警告走自绘浮层,见 `TakePendingHint`;
// 不使用 Windows 消息框)。
// ⚠️ **等效于 `f(t)` 的公式按“未启用”处理**(清空):它逐点恒等,
// 但却会让 `HasFormula()` 为真 ⇒ 关闭粗粒度包络层、逼得自适应量程
// 每帧逐点扫描整条信号(千万级采样 ⇒ 界面完全卡死,用户 2026-11-03 报告)。
const std::wstring normalized = NormalizeFormulaText(trimmed);
if (normalized.empty() || FormulaIsIdentity(normalized))
{
style->formula.clear();
}
else
{
std::wstring error;
if (!ValidateFormula(normalized, &error))
{
m_pendingHint = L"公式无法解析:" + error;
}
else
{
style->formula = normalized;
}
}
if (m_menu != nullptr)
{
m_menu->UpdateItemValue(CMD_FORMULA_EDIT, FormulaDisplayText(style->formula),
FormulaEditText(style->formula));
}
return false;
}
case CMD_GAIN_EDIT:
{
if (valid)
@@ -391,6 +446,21 @@ namespace jdi
}
}
// 标题栏右侧的「还原」图标:公式恢复为默认的 `f(t)`(= 等值照搬)。
if (result.id == CMD_FORMULA_RESET)
{
if (SignalStyle* style = StyleOf(SelectedRef()))
{
style->formula.clear();
if (m_menu != nullptr)
{
m_menu->UpdateItemValue(CMD_FORMULA_EDIT, FormulaDisplayText(style->formula),
FormulaEditText(style->formula));
}
}
return false;
}
// 标题栏右侧的「还原」图标:系数重置为默认值 1(面板保持打开,输入框立即回显)。
if (result.id == CMD_GAIN_RESET)
{
@@ -904,9 +904,10 @@ namespace jdi
UiText(g, label, m_fonts->uiSmall, rect,
checked ? m_theme->textStrong : m_theme->text, TextAlign::CENTER);
};
// 系数/偏移:系数不等于默认值 1、或任一偏移不为 0 时显示为"已启用"。
paintToggle(kAdjustGain, L"系数/偏移",
!GainIsDefault(style->gain) || style->Offset(), StyleHit::GAIN_ADJUST);
// 映射/偏移:公式映射非空、系数不等于默认值 1、或任一偏移不为 0 时显示为"已启用"。
paintToggle(kAdjustGain, L"映射/偏移",
style->HasFormula() || !GainIsDefault(style->gain) || style->Offset(),
StyleHit::GAIN_ADJUST);
// 镜像:水平或垂直任一被勾选即显示为激活态。
paintToggle(kAdjustMirror, L"镜像", style->Mirrored(), StyleHit::MIRROR_ADJUST);
paintToggle(kAdjustAbsolute, L"绝对值", style->absolute, StyleHit::ABSOLUTE_TOGGLE);
+161
View File
@@ -9498,3 +9498,164 @@ make release / make debug 零 warning;Release 814,080 B
`paintOverlays()` 里,否则只会在整幅重绘的那一帧出现。
* ⚠️ PowerShell 5.1 的 `-replace`/多行字符串在含中文/引号时会把命令搞坏;改文件内容优先
用编辑工具或 `.ps1` + `[System.IO.File]::WriteAllText`。
## 2026-11-02 第二十四轮(本机,`dev` 分支):实验性功能「重采样」+ 样式区「映射/偏移」公式映射
> 本条为**硬约束**,后续改动若与本条冲突,以本条为准(远端合并时请保留并合并,不要覆盖)。
### A. 「重采样」(实验性功能,位于「自由标记」**上方**)
1. **数据模型**:`Lane::ResampleItem { ref, interval, initialized, maxInterval, minInterval, collapsed }`
+ `Lane::{resample, resampleItems, resamplePanel, resampleScroll}`;
`LaneCarry` / `SplitLane` / `PruneLaneSignalItems` / `RemapAfterSignalInsert` /
`RemapAfterSignalRemoval` **必须全部同步**(漏一处就会出现"拆网格后功能复制/条目指向错信号")。
2. **面板**:与「均值滤波 / 中心轴 / 弱峰调整」同一套**可滚动分组列表**(信号色叠底条目栏 +
右侧复选框 + 展开三角);展开后 4 行:最大数据间隔(只读)/ 最小数据间隔(只读)/
重采样间隔(输入,秒)/ 重采样频率(输入,Hz)。底部「构建为新信号」固定不滚动。
3. ⚠️ **最大/最小数据间隔绝不能在绘制时计算**:千万级信号逐帧扫描会卡死。勾选该信号时
(`SetLaneResample` / `ToggleLaneResampleSignal`)一次性算好并缓存进 `ResampleItem`,
绘制只读字段。
4. ⚠️ **重采样产生新的时间刻度** ⇒ 必须 `DataSet::AppendGroupAxis()` 追加一条新 `TimeAxis`,
再 `InsertDerivedSignal`(`axisIndex` 指向新轴)。**不能复用源信号的时间轴**(否则采样与时间错位)。
5. 采样值先按「公式映射 → 系数 → 绝对值 → 垂直镜像 → Y 偏移」变换(与绘制一致),
再在 `[t0, t1]` 上等间隔线性插值;**时间轴偏移不烘值**(留在新信号样式上,与其它派生信号同规则)。
6. 时间轴非单调 / 有效点 < 2 / 间隔 > 跨度 / 点数 > 2000 万 ⇒ **拒绝构建**并用
`PlotView::ShowPersistentHint` 提示(不弹 Windows 消息框)。
7. 新信号名 = `<原信号> (重采样 <频率>)`,频率单位在 Hz/kHz/MHz/GHz 自适应(`Utf8Util::FormatFrequency`)。
### B. 样式区「系数/偏移」→「映射/偏移」+ 公式映射
1. 按钮文案改为 **「映射/偏移」**;激活态 = 公式非空 **或** 系数 ≠ 1 **或** 任一偏移 ≠ 0。
2. 二级面板顶部新增 **「公式映射」**(标题栏带还原图标):`Y =` + 文本输入框,默认 `f(t)`。
复位 ⇒ `style->formula.clear()`(即以 `f(t)` 显示/生效)。
3. ⚠️ **结算顺序固定为:公式映射 → 系数 → 绝对值 → 垂直镜像 → Y 偏移 → 时间轴偏移**
(唯一实现在 `ApplyDrawTransform`,不要在别处重复实现公式)。
4. 表达式编译/求值唯一实现 = `Utilities/Inc|Src/FormulaEval.{hpp,cpp}`:
语法 `+ - * / ( )`、一元正负、数字(含 e/E 指数)、常量 `pi`、自变量 `X` / `x` / `f` / `f(t)`。
⚠️ 求值走 `thread_local` 单条缓存(按 `wstring.data()` 指针 + 内容判定),
**绝不逐采样点重新解析**(千万级采样会慢到不可用)。
5. 非法表达式:**不写入样式**,警告经 `SignalPanel::TakePendingHint` → 宿主
`m_plot.ShowPersistentHint()`(与"加载失败"同一套自绘浮层,**不用 MessageBox**)。
输入合法时把 `X`/`x`/`f` 归一化为 `f(t)` 再保存(`NormalizeFormulaText`)。
6. ⚠️ **公式会影响缓存与包围盒,四处必须同步**:
* `AcquirePolyline` 折线缓存键;
* `SceneContentHash`(→ 场景/宽基底缓存失效);
* `SceneStyleEntry`(→ 局部重画判据)+ `HaloLayerCache`(→ 悬停光晕);
* `DrawTransformOrderPreserving()` 返回 **false**(公式不保序,粗粒度包络层不能用);
自动幅值范围在 `HasFormula()` 时必须**逐点扫描**(`TabConfig::ComputeLaneRange`)。
7. 派生信号 / 克隆的烘焙自动包含公式(两者都走 `ApplyDrawTransform`),**时间轴偏移除外**。
### C. 验证(针对性,仅本轮功能)
`.preflight/devcheck`(`build.ps1` + `main.cpp`,**`.preflight` 已被 gitignore**)62/62:
公式解析/求值/归一化/结算顺序/非法回退、重采样端到端(新轴、5 点、插值精确、名称尾缀、
面板几何、默认勾选最小间隔)、`SignalDataInterval`、频率格式化、公式影响自适应量程。
⚠️ 链接 Build/Debug/*.o(含 LTO)**必须加 `-flto -flto-partition=one`**,否则 `binds_to_current_def_p` ICE。
⚠️ 探针里必须先 `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 红线)。
+147 -1
View File
@@ -13,6 +13,7 @@
#include "DataLoader.hpp"
#include "DataTable.hpp"
#include "FormulaEval.hpp"
namespace jdi
{
@@ -193,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 单个信号的显示外观(颜色/透明度/线宽/绘制方式/数值变换)。
*
@@ -230,6 +275,23 @@ namespace jdi
float offsetY = 0.0f; /**< Y 轴偏移(叠加到绘制值上),默认 0。 */
double offsetTime = 0.0; /**< 时间轴偏移(秒,绘制在 t + offsetTime 处),默认 0。 */
/**
* @brief 「公式映射」表达式(`Y = <formula>`,自变量写作 `f(t)`;空 = 不映射)。
*
* 结算顺序为 **公式映射 → 系数 → 绝对值 → 垂直镜像 → Y 偏移 → 时间轴偏移**,
* 见 `ApplyDrawTransform`。表达式由 `Utilities/FormulaEval` 编译(带缓存),
* 非法表达式的取值原样返回采样值(不会让波形消失)。
*
* ⚠️ **`f(t)`(恒等映射)与空串等价**:`HasFormula()` 两者都返回 false。
* 否则用户只是“点进输入框又点出去”就会让样式带上一个恒等公式,
* 而公式会关闭粗粒度包络层、逼得自适应量程每帧逐点扫描整条信号
* (千万级采样时界面直接卡死,用户 2026-11-03 报告)。
*/
std::wstring formula;
/** @brief 是否**真的**启用了「公式映射」(`f(t)` 恒等映射不算)。 */
bool HasFormula() const { return !FormulaIsIdentity(formula); }
/** @brief 镜像功能的任一分量是否启用(决定「镜像」按钮的激活态)。 */
bool Mirrored() const { return mirrorVertical || mirrorHorizontal; }
@@ -275,6 +337,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<double>(style.gain);
@@ -325,7 +393,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 +639,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 +914,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<SignalRef> signals; /**< 该网格包含的信号(按加入顺序)。 */
bool autoRange = true; /**< 是否依据信号取值范围自动计算 Y 轴。 */
@@ -1102,6 +1212,42 @@ namespace jdi
*/
float edgeCountScroll = 0.0f;
/**
* @brief 「实验性功能 → 重采样」开关(功能总开关)。
*
* 开启后本网格出现浮动面板:以网格内每个信号为条目(信号色叠底 + 复选框 +
* 展开/收起),展开后显示「最大数据间隔 / 最小数据间隔」两行统计与
* 「重采样间隔 / 重采样频率」两行**联动输入框**;底部「构建为新信号」按当前参数
* 把勾选的信号重采样成固定频率并插到源信号下方。
*
* 与其它实验性功能一样属于**网格级**状态,重建网格时按信号继承(只由原网格首个信号持有)。
*/
bool resample = false;
/** @brief 参与重采样的信号及其参数(只包含被勾选的信号)。 */
std::vector<ResampleItem> 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 「自由标记」面板里被**收起**的信号分组(点击分组栏切换)。
*
+17
View File
@@ -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<SignalRef> freeMarkerCollapsedGroups;
/** @brief 「重采样」开关与逐信号参数(详见 `Lane::resample`)。 */
bool resample = false;
std::vector<Lane::ResampleItem> 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;
};
// 收集全部可见信号引用。
+63
View File
@@ -0,0 +1,63 @@
#ifndef __FORMULA_EVAL_HPP__
#define __FORMULA_EVAL_HPP__
// 「公式映射」的表达式编译与求值(信号样式区的 `Y = …` 输入框)。
//
// 语法(刻意保持极小,避免引入脚本引擎与额外的体积):
// expr := term (('+' | '-') term)*
// term := unary (('*' | '/') unary)*
// unary := ('+' | '-') unary | power
// power := primary ('^' unary)? —— 右结合:2^3^2 = 2^(3^2)
// primary := number | '(' expr ')' | variable | constant
// number := 十进制小数(支持 e/E 指数)
// constant:= `e`(自然常数)| `pi`(圆周率)—— 大小写均接受,保存前统一为小写
// variable:= `X` / `x` / `f(t)` —— 都表示"当前采样点的值"(**不支持单独的 `f`**)
//
// ⚠️ 求值走 `thread_local` 的单条缓存(按 `std::wstring` 的 data 指针 + 内容判定):
// 绘制循环里同一条信号的样式对象是稳定的,因此每个信号只需编译一次;
// **绝不能**每个采样点都重新解析(千万级采样会慢到不可用)。
#include <string>
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(t)`(含 `F(T)`)记号改写为 `f(t)`;
* * 把 `E` / `Pi` / `PI` 等写法改写为小写 `e` / `pi`;
* * **单独的 `f` 不改写**(它不是合法写法,留给校验报错)。
*
* 只做记号级替换(不会动标识符中间或数字里的字符,如 `1e5` 保持不变)。
*
* @param [IN] text 用户输入的公式。
* @return 归一化后的文本。
*/
std::wstring NormalizeFormulaText(const std::wstring& text);
} // namespace jdi
#endif // __FORMULA_EVAL_HPP__
+26
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 刻度值。
@@ -121,6 +137,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 坐标轴数值跨度,必须为正数。
+54
View File
@@ -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<int>(m_groups.size()))
{
return false;
}
if (axis.time.empty())
{
return false;
}
SignalGroup& group = m_groups[static_cast<std::size_t>(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
+48
View File
@@ -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;
}
}
}
}
@@ -690,9 +709,38 @@ namespace jdi
double signalLow = signal->minValue;
double signalHigh = signal->maxValue;
if (style != nullptr)
{
if (style->HasFormula())
{
// ⚠️ 「公式映射」不保证单调(`x*x` / `1/x`)⇒ 端点映射会算错,
// 只能逐点扫描(只在自适应重算时发生,不是每帧路径)。
double formulaLow = std::numeric_limits<double>::infinity();
double formulaHigh = -std::numeric_limits<double>::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);
}
+638
View File
@@ -0,0 +1,638 @@
// 「公式映射」表达式的编译 / 求值实现(详见 `FormulaEval.hpp`)。
#include "FormulaEval.hpp"
#include <cmath>
#include <cstddef>
#include <cstdlib>
#include <cwctype>
#include <limits>
#include <vector>
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,
POWER, /**< 幂(`a ^ b`)。 */
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,
CARET,
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<Token>& 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<wint_t>(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::CARET, 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;
}
// 指数部分必须**真的带数字**才算数字的一部分:
// `2e` 里的 `e` 是自然常数(不能把 `e` 吞进食指后当成数字 2 再用掉它)。
if (index < length && (text[index] == L'e' || text[index] == L'E'))
{
std::size_t probe = index + 1;
if (probe < length && (text[probe] == L'+' || text[probe] == L'-'))
{
++probe;
}
if (probe < length && text[probe] >= L'0' && text[probe] <= L'9')
{
index = probe;
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<wint_t>(ch)) != 0)
{
// 标识符:只允许 X / x / f(t) 作为数据点,以及常量 e / pi。
const std::size_t start = index;
while (index < length && std::iswalpha(static_cast<wint_t>(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<wint_t>(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<wint_t>(text[inner])) != 0)
{
++inner;
}
if (inner < length && (text[inner] == L't' || text[inner] == L'T'))
{
++inner;
while (inner < length && std::iswspace(static_cast<wint_t>(text[inner])) != 0)
{
++inner;
}
if (inner < length && text[inner] == L')')
{
hasArgument = true;
index = inner + 1;
}
}
}
for (wchar_t& letter : name)
{
letter = static_cast<wchar_t>(std::towlower(static_cast<wint_t>(letter)));
}
if (name == L"x" || (name == L"f" && hasArgument))
{
tokens.push_back(Token{TokenKind::VARIABLE, 0.0});
}
else if (name == L"pi")
{
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;常量只支持 e / pi)。";
}
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<Token>& tokens, std::vector<FormulaOp>& 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 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()
{
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<Token>& m_tokens;
std::vector<FormulaOp>& m_output;
std::wstring* m_error;
std::size_t m_position = 0;
};
/** 编译结果(逆波兰式);空表示"不可用"。 */
using Program = std::vector<FormulaOp>;
/** 线程内的单条编译缓存(按内容判定)。 */
struct FormulaCache
{
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;
// ⚠️⚠️ **必须比较内容,不能只比 `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;
}
cache.text = text;
cache.size = text.size();
cache.program.clear();
cache.valid = false;
cache.compiled = true;
std::vector<Token> 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<Token> tokens;
if (!Tokenize(text, tokens, error))
{
return false;
}
std::vector<FormulaOp> 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<double>::quiet_NaN();
}
stack[depth++] = (op.kind == FormulaOp::Kind::NUMBER) ? op.value : x;
break;
case FormulaOp::Kind::NEGATE:
if (depth < 1)
{
return std::numeric_limits<double>::quiet_NaN();
}
stack[depth - 1] = -stack[depth - 1];
break;
default:
{
if (depth < 2)
{
return std::numeric_limits<double>::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;
case FormulaOp::Kind::POWER:
result = std::pow(a, b);
break;
default:
break;
}
stack[depth - 1] = result;
break;
}
}
}
if (depth != 1)
{
return std::numeric_limits<double>::quiet_NaN();
}
return std::isfinite(stack[0]) ? stack[0] : std::numeric_limits<double>::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<wint_t>(ch)) == 0)
{
output.push_back(ch);
++index;
continue;
}
const std::size_t start = index;
while (index < length && std::iswalpha(static_cast<wint_t>(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<wint_t>(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<wint_t>(text[inner])) != 0)
{
++inner;
}
if (inner < length && (text[inner] == L't' || text[inner] == L'T'))
{
++inner;
while (inner < length && std::iswspace(static_cast<wint_t>(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<wchar_t>(std::towlower(static_cast<wint_t>(letter)));
}
// 数据点:`X` / `x` / `f(t)`(含 `F(T)`)一律写成规范形式 `f(t)`。
// ⚠️ **单独的 `f` 不归一化**:它不是合法写法(用户 2026-11-03 明确要求),
// 原样输出交给 `ValidateFormula` 报错,而不是悄悄当成 `f(t)` 接受。
if (lowered == L"x" || (lowered == L"f" && hasArgument))
{
output += L"f(t)";
}
// 常量统一为小写(`E` / `Pi` / `PI` → `e` / `pi`)。
else if (lowered == L"e")
{
output += L"e";
}
else if (lowered == L"pi")
{
output += L"pi";
}
else
{
output += name;
}
}
return output;
}
} // namespace jdi
+100 -5
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,36 @@ namespace jdi
{
return L"0";
}
return FormatValue(std::fabs(seconds));
// ⚠️ 内联编辑的数值**不得出现科学计数法**(用户 2026-11-03 要求)。
return FormatDecimal(std::fabs(seconds), 12);
}
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;
}
// ⚠️ 频率同样不得出现科学计数法(用户 2026-11-03 要求):
// `FormatValue` 的 `%.6g` 在 1e-5 Hz 这类量级会输出 “1e-06”,改为十进制形式。
return FormatDecimal(scaled) + L" " + unit;
}
std::wstring FormatByteSize(unsigned long long bytes)