修复:适应视图不跟随时间轴偏移 + 派生信号构造成空信号

## 为什么

用户 2026-10-29 报告两条功能异常:

1. 给某条信号设置「调整 → 时间轴」偏移后,再点「适应视图」,视界没有跟着平移,
   曲线整体跑到视界之外;
2. 用「均值滤波 / 弱峰调整」的「构建为新信号」派生出来的信号是**空的**(有一个网格但画不出任何东西)。

## 根因

### ① 时间边界没有叠加 offsetTime

采样实际画在 `t + SignalStyle::offsetTime` 处,但"取景用的时间范围"只统计了时间轴的
源时刻下限/上限(`axis.minTime/maxTime`),三处都漏了偏移:

- `TabConfig::ComputeVisibleTimeBounds`(「适应视图」/视图裁剪的时间范围来源)
- `PlotView::CollectFitContributions`(`minTime`/`maxTime` 与单值点的 `pointTime`)
- `PlotView::FitTimeToLane`(网格右键「按网格时间轴展开」)

于是偏移后取景仍停留在原始时间范围。

### ② 派生信号把时间偏移"烘"进采样值

`Application::BuildDerivedSignal` 的 2c 步按 `time[i] - offsetTime` 重采样,把平移写进采样值。
派生信号与源信号**共用同一条文件时间轴**,所以平移出轴范围的那一段只能写成 NaN:
偏移 ≥ 数据跨度时整条信号变成全 NaN(= 空信号),偏移很小时也会在两端留下与实际曲线不符的空洞。

预览曲线本来就用**源样式的 `offsetTime`** 绘制(`PaintDerivedCurve` 里 `TimeToX(time + offsetTime)`),
因此把偏移留在派生信号的样式上,显示与预览一致,且采样数据一点都不会丢。

## 改了什么

1. `Utilities/Src/DataModelTab.cpp`:`ComputeVisibleTimeBounds` 逐信号叠加 `style->offsetTime`
   (`low = min(low, axis.minTime + off)`、`high = max(...)`)。
2. `Components/PlotView/Src/PlotViewFeatures.cpp`:
   - `CollectFitContributions` 的 `minTime/maxTime` 叠加偏移;单值点的 `pointTime` 改为**显示时刻**
     (`time + offsetTime`),视界居中与边界让位都以它为准;
   - `FitTimeToLane` 同样叠加偏移。
3. `Components/Application/Src/ApplicationCommands.cpp`:
   - 删除 2c(重采样烘值),时间偏移改由**新信号样式**表达(3b 变成唯一路径);
   - 同步重写两处注释说明"为什么不再烘值"。
4. 文档:`DataModel.hpp::ComputeVisibleTimeBounds`、`PlotView.hpp::{TimeBounds,FitContribution}`
   的注释更新为"显示时间(含 offsetTime)"。
5. `Documents/agent_mem.md`:追加第十四轮,并把两条旧规则显式标注为**已作废**
   (时间边界只取源时刻 / 派生信号烘值),写明现行做法与理由。

## 副作用(有意为之)

派生信号若源自"带时间轴偏移"的信号,它的「调整」面板里「时间轴」会显示该偏移值。
旧实现为了让面板显示默认值而烘值,代价是数据被裁掉甚至整条变空 —— 这个取舍不可接受。

## 怎么验证

新增离屏探针 `.preflight/derivecheck`(16 条断言,不占鼠标/键盘/屏幕):

```
INFO signals=10 axis=[0.000000, 1.260000] span=1.260000
mean:     values=1009 finiteCount=1009      -> 有数据、范围不越界
weakPeak: values=1009 finiteCount=1009      -> 有数据
offset=0.126000 fitView=[0.126000, 1.386000] (期望 [0.126, 1.386])-> 适应视图跟随偏移
offset-derived:  values=1009 finite=1009    (修复前 908)
huge-offset-derived: values=1009 finite=1009 styleOffset=2.520000 (修复前全 NaN)
16 passed, 0 failed
```

回归(全部通过):

```
make release / make debug                 零 error / 零 warning
uicheck 69/69   lbcheck 21/21   lbresid 5/5   lbzorder 4/4
lbcovr   7/7    lbpanel 10/10   derivecheck 16/16
rendercheck: 8 个场景哈希与上一轮逐一相同 + FUNC 7/7, exit=0
  A FAED59DF284153DB  B E6443BFE4E3955F8  C 96A0AA8C402F1729  D E3D5DBD998E43720
  E DA0583CB13C26A6F  F FFB822159F604D1D  G 19F8DE8FE3A8DF18  H DBBDDE4CB2C97C92
```

产物仍是 **788,992 B**(单一 exe,未拆 DLL,未改优化等级)。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-10-29 13:48:25 +08:00
1 parent 044b87383b
commit 71c6ff91cb
6 files changed
+91 -68

No files matched your search

@@ -650,7 +650,6 @@ namespace jdi
return false;
}
const SignalData& source = group->signals[static_cast<std::size_t>(request.signalIndex)];
const TimeAxis& axis = group->AxisOf(source);
const std::wstring baseName = source.name;
// 0. 先把源信号的「调整」变换快照下来(下面 `InsertDerivedSignal` 会移动信号存储,
@@ -663,10 +662,13 @@ namespace jdi
// * 数值域变换(系数 → 绝对值 → 垂直镜像 → Y 偏移)**与**水平镜像(数据反序)
// 全部作用在派生采样值上;
// * 于是新信号**用默认外观**绘制出来就与预览曲线逐位一致。
// * ⚠️ **时间轴偏移也一并烘进数据**(见 2c:按 `time[i] - offsetTime` 重采样)。
// 新信号与源信号共用同一个文件时间轴,因此时间平移只能写进采样值 —— 这样
// 新信号**不启用任何「调整」**就与预览逐点重合(用户明确要求)。
// 只有"无法烘值"的退化情形才退回把 `offsetTime` 带在样式上(见 3b)。
// * ⚠️ **只有「数值域」的变换烘进采样值;时间轴偏移留在样式上**(见 3b)。
// 曾经把 `offsetTime` 也按 `time[i] - offsetTime` 重采样烘进采样值("新信号
// 不启用任何调整"),但那是**有损**的:平移出时间轴范围的那一段只能写成 NaN,
// 偏移稍大(≥ 数据跨度)就连一个有效采样都不剩 —— 用户点「构建为新信号」
// 得到的是一条**空信号**(2026-10-29 报告)。派生信号与源信号共用同一条文件
// 时间轴,无法用采样值表达超出轴范围的平移,因此时间偏移只能由样式表达。
// 预览曲线本来也是用源样式的 `offsetTime` 绘制的,两条路径**显示完全一致**。
SignalStyle sourceTransform;
bool hasSourceStyle = false;
{
@@ -726,54 +728,15 @@ namespace jdi
}
}
// 2c. 时间轴偏移:把 `offsetTime` 也**重采样烘进采样值**。
// 2c.(已删除)时间轴偏移**不再**按 `time[i] - offsetTime` 重采样烘进采样值。
//
// 预览曲线是按「源信号样式」绘制的,它的时间轴偏移会把曲线整体横向平移。
// 派生信号与源信号**共用同一个文件时间轴**,所以时间平移本无法写进"时间列";
// 但用户明确要求:**新信号不启用任何「调整」也要与预览逐点重合**
//(新信号的「调整」面板必须是默认值,不允许靠"继承 offsetTime"来表达)。
// 因此这里把平移直接写进数据:
// * 新信号第 i 个采样 = 预览曲线在 `time[i]` 时刻显示的值
// = 已变换的曲线在 `time[i] - offsetTime` 处的取值(取最近的采样点);
// * 平移后落在数据范围之外的采样置 NaN —— 与预览"该处没有曲线"完全一致。
// ⚠️ 一次整段重采样需要一份等长的临时缓冲(千万级采样 ≈ 数十 MB),用完立即释放;
// 这是"构建为新信号"的一次性操作,不在交互路径上。
// ⚠️ 时间轴非单调时无法按下标单调推进取样(只能整表扫描),此时退回"继承
// `offsetTime`"(见 3b):两条路径的**显示效果相同**,只是偏移留在样式里。
bool timeShiftBaked = false;
if (hasSourceStyle && sourceTransform.offsetTime != 0.0 && axis.monotonic)
{
const std::vector<double>& times = axis.time;
const std::size_t total = derived.values.size();
if (total > 1 && times.size() >= total)
{
const double shift = sourceTransform.offsetTime;
std::vector<double> shifted(total, std::numeric_limits<double>::quiet_NaN());
// 目标时刻 `times[index] - shift` 随 index 单调不减 → 游标只前进,整体 O(n)。
std::size_t probe = 0;
for (std::size_t index = 0; index < total; ++index)
{
const double target = times[index] - shift;
if (!std::isfinite(target) || target < times[0] || target > times[total - 1])
{
continue; // 平移后越界:该处没有曲线
}
while (probe + 1 < total && times[probe + 1] < target)
{
++probe;
}
std::size_t nearest = probe;
if (probe + 1 < total &&
std::fabs(times[probe + 1] - target) < std::fabs(times[probe] - target))
{
nearest = probe + 1;
}
shifted[index] = derived.values[nearest];
}
derived.values.swap(shifted);
timeShiftBaked = true;
}
}
// 旧实现为了让"新信号的「调整」面板保持默认"而把平移烘进数据,代价是**有损**:
// 平移出时间轴范围的那一段只能写成 NaN,偏移 ≥ 数据跨度时**整条信号全变 NaN**
//(用户 2026-10-29 报告的"派生出来是一条空信号");即便偏移很小,也会在两端
// 留下与实际曲线不符的空洞。
// 派生信号与源信号共用同一条文件时间轴,无法用采样值表达超出轴范围的平移,
// 因此时间偏移改由**新信号样式**表达(见 3b)——预览曲线本来就用源样式的
// `offsetTime` 绘制,所以两条路径的**显示完全一致**,且数据一点都不会丢。
// 3. 插入到原信号的下方(数据层 + 各标签页引用重映射)。
int newIndex = -1;
@@ -789,10 +752,10 @@ namespace jdi
tab.RemapAfterSignalInsert(request.groupIndex, newIndex);
}
// 3b. 时间轴偏移正常情况下已烘进采样值(见 2c),新信号的「调整」全部保持默认。
// ⚠️ 只有"无法烘值"的退化情形(时间轴非单调 / 时间列缺失)才退回到把
// `offsetTime` 带在新信号样式上 —— 无论如何都要让**显示**与预览一致。
if (hasSourceStyle && sourceTransform.offsetTime != 0.0 && !timeShiftBaked)
// 3b. 时间轴偏移:**只**写进新信号的样式(不再烘进采样值,见 2c 的说明)。
// 绘制路径本来就把采样画在 `t + offsetTime` 处,因此新信号的曲线与预览曲线
// 逐点重合,同时采样数据保持完整(不会因为平移越界而变成 NaN)。
if (hasSourceStyle && sourceTransform.offsetTime != 0.0)
{
if (SignalStyle* derivedStyle = m_dataSet.Style(SignalRef{request.groupIndex, newIndex}))
{
+6 -3
View File
@@ -544,10 +544,10 @@ namespace jdi
struct FitContribution
{
SignalRef ref; /**< 信号引用。 */
double minTime = 0.0; /**< 时间轴下限(源时刻,与 `ComputeVisibleTimeBounds` 同源)。 */
double maxTime = 0.0; /**< 时间轴上限。 */
double minTime = 0.0; /**< **显示时间**下限(= 时间轴下限 + 该信号的 `offsetTime`)。 */
double maxTime = 0.0; /**< **显示时间**上限。 */
bool singlePoint = false; /**< 是否只有一个有效数据点(画出来就是一个圆点)。 */
double pointTime = 0.0; /**< 单值时:数据点的源时刻。 */
double pointTime = 0.0; /**< 单值时:数据点的**显示时刻**(源时刻 + `offsetTime`)。 */
double pointValue = 0.0; /**< 单值时:**绘制变换后**的取值。 */
float dotRadius = 0.0f; /**< 单值时:散点半径(设备像素)。 */
};
@@ -1336,6 +1336,9 @@ namespace jdi
/**
* @brief 计算当前视图应使用的时间范围。
*
* 返回的是**显示时间**(采样实际画在 `t + SignalStyle::offsetTime` 处),
* 因此"适应视图"与视图裁剪都会把时间轴偏移算进去。
* @param [OUT] low 时间下限。
* @param [OUT] high 时间上限。
*/
+16 -6
View File
@@ -224,12 +224,19 @@ namespace jdi
continue;
}
// ⚠️ 该信号自己的时间轴偏移:采样画在 `t + offsetTime` 处,
// 因此"这段数据在屏幕上占用的时间范围"与"单值数据点的时刻"都要加上它。
// 少了这一项,「适应视图」在信号被时间偏移后仍按原始时间范围取景
// (用户 2026-10-29 报告的缺陷)。
const SignalStyle* style = m_dataSet->Style(ref);
const double timeShift = (style != nullptr) ? style->offsetTime : 0.0;
FitContribution item;
item.ref = ref;
// 与 `ComputeVisibleTimeBounds` 同源:时间边界用**时间轴**的范围
//(一个分组的多条信号可能共用一条轴,轴范围才是"这一段数据"的边界)。
item.minTime = axis->minTime;
item.maxTime = axis->maxTime;
item.minTime = axis->minTime + timeShift;
item.maxTime = axis->maxTime + timeShift;
item.singlePoint = (data->finiteCount == 1);
if (item.singlePoint)
{
@@ -241,8 +248,8 @@ namespace jdi
}
else
{
const SignalStyle* style = m_dataSet->Style(ref);
item.pointTime = time;
// 同样是**显示时刻**(`time + offsetTime`):视界的居中与边界让位都以它为准。
item.pointTime = time + timeShift;
item.pointValue =
(style != nullptr) ? ApplyDrawTransform(*style, value) : value;
item.dotRadius = SinglePointDotRadius(ResolveSignal(ref));
@@ -706,8 +713,11 @@ namespace jdi
{
continue;
}
low = std::min(low, axis->minTime);
high = std::max(high, axis->maxTime);
// 与 `TimeBounds` 同源:叠加该信号自己的时间轴偏移(采样画在 t + offsetTime 处)。
const SignalStyle* style = m_dataSet->Style(ref);
const double timeShift = (style != nullptr) ? style->offsetTime : 0.0;
low = std::min(low, axis->minTime + timeShift);
high = std::max(high, axis->maxTime + timeShift);
}
if (!std::isfinite(low) || !std::isfinite(high) || !(high > low))
+35
View File
@@ -8465,3 +8465,38 @@ rendercheck: A FAED59DF284153DB / B E6443BFE4E3955F8 / C 96A0AA8C402F1729 / D E3
"超在哪一段(`.text` / `.rdata` / `.xdata`+`.pdata` / `.rsrc`)、由哪个改动贡献"。
查段构成的标准命令:`objdump -h Build\Release\JustDataInspector.exe`。
当前 788,992 B,余量 259,584 B。
## 2026-10-29 第十四轮(本机):「适应视图」跟随时间轴偏移 + 派生信号不再为空
> 用户报告两条功能异常(都源于「调整 → 时间轴」偏移 + 派生信号烘值):
> ① 给信号设置时间轴偏移后点「适应视图」,视界没有跟着平移;
> ② 用「均值滤波 / 弱峰调整 → 构建为新信号」派生出来的信号是**空的**。
### 1. 硬规则变更(**推翻旧结论,务必按此**)
| # | 已作废的规则 | 现行做法 |
| --- | --- | --- |
| 1 | 时间边界只取 `axis.minTime/maxTime`(源时刻) | **必须叠加该信号自己的 `SignalStyle::offsetTime`**(采样画在 `t + offsetTime` 处)。三处入口:`TabConfig::ComputeVisibleTimeBounds`、`PlotView::CollectFitContributions`(`minTime/maxTime` 与 `pointTime`)、`PlotView::FitTimeToLane` |
| 2 | 派生信号把 `offsetTime` 按 `time[i] - offsetTime` **重采样烘进采样值**(2026-10-21 的"烘值"规则) | **不再烘值**:时间偏移一律写进**新信号的样式**(`derivedStyle->offsetTime`)。理由:烘值有损 —— 平移出时间轴范围的那一段只能写 NaN,偏移 ≥ 数据跨度时整条信号全 NaN(= 空信号);而且即便偏移很小,两端也会出现与预览不符的空洞 |
### 2. 根因与验证
* **①** `TimeBounds()` → `ComputeVisibleTimeBounds()` 只统计 `axis.minTime/maxTime`,
`FitContribution::{minTime,maxTime,pointTime}` 同样是源时刻 ⇒ 偏移后取景仍停留在原始时间范围,
曲线整体跑到视界之外。
* **②** `BuildDerivedSignal` 的 2c 步把偏移烘进采样值。派生信号与源信号**共用同一条文件时间轴**,
无法用采样值表达超出轴范围的平移 ⇒ 只能丢成 NaN。
预览曲线本来就用源样式的 `offsetTime` 绘制(`PaintDerivedCurve`:`TimeToX(time[index] + style->offsetTime)`),
所以**把偏移留在派生信号的样式上,显示与预览逐字节一致,且数据一点不丢**。
* 新探针 `.preflight/derivecheck`(16/16):无偏移的均值滤波/弱峰调整有数据且范围不越界;
单信号 +10% 偏移后 `FitAll` 视界 = `[min+off, max+off]`;偏移 10% 与 200% 时派生信号都保持
`finite == values.size()`,且样式上带着偏移。
* 回归:`uicheck` 69、`lbcheck` 21、`lbresid` 5、`lbzorder` 4、`lbcovr` 7、`lbpanel` 10、
`derivecheck` 16 全过;`rendercheck` 8 场景哈希**与上一轮逐一相同** + FUNC 7/7;零 warning;
产物仍是 **788,992 B**。
### 3. 副作用(已知、可接受)
派生信号如果源自一个"带时间轴偏移"的信号,它的「调整」面板里「时间轴」会显示该偏移值
(不再像以前那样强行"默认值")。这是**刻意**的:时间偏移无法无损地写进采样值,
把偏移诚实显示出来比"面板是默认值但数据被裁掉/变空"要好。
+6 -1
View File
@@ -1463,7 +1463,12 @@ namespace jdi
void ComputeLaneRange(const DataSet& dataSet, int laneIndex, double& yMin, double& yMax) const;
/**
* @brief 计算全部可见信号覆盖的时间范围。
* @brief 计算全部可见信号覆盖的**显示时间**范围。
*
* ⚠️ 返回的是"画出来的位置",即每条信号的 `axis.minTime/maxTime` 已叠加它自己的
* `SignalStyle::offsetTime`(采样画在 `t + offsetTime` 处)。视界要覆盖的正是这个范围,
* 少了偏移就会出现"设置时间轴偏移后点适应视图,曲线跑到视界之外"。
*
* @param [IN] dataSet 数据集合。
* @param [OUT] minTime 输出下限。
* @param [OUT] maxTime 输出上限。
+9 -2
View File
@@ -741,8 +741,15 @@ namespace jdi
{
continue;
}
low = std::min(low, axis.minTime);
high = std::max(high, axis.maxTime);
// ⚠️ **必须叠加该信号自己的时间轴偏移**(`SignalStyle::offsetTime`):
// 采样实际画在 `t + offsetTime` 处,视界要覆盖的是「画出来的位置」。
// 少了这一项,「调整 → 时间轴」设置了偏移后点「适应视图」仍会用原始时间范围,
// 用户看到的是曲线整体偏移到视界之外(用户 2026-10-29 报告的缺陷)。
const SignalStyle* style = dataSet.Style(
SignalRef{static_cast<int>(groupIndex), static_cast<int>(signalIndex)});
const double timeShift = (style != nullptr) ? style->offsetTime : 0.0;
low = std::min(low, axis.minTime + timeShift);
high = std::max(high, axis.maxTime + timeShift);
}
}