「调整」行:系数/偏移 按钮 + 偏移调整(Y 轴/时间轴)二级面板;结算顺序加入偏移;派生信号继承调整变换

按钮与面板:
- 「系数」按钮改名为「系数/偏移」;点击展开的二级面板在原「系数调整」输入框下方新增「偏移调整」:
  与「系数调整」同一套绘制(标题 + 标题栏右侧的还原图标),下面两行分别是「Y轴」「时间轴」,
  每行左侧说明文字、右侧内联输入框(整行数值框)。
- 「Y轴」默认 0.00,非编辑态**始终带正负号**(+0.00 / -12.50),小数不足两位时补两位、更高精度保留更多位。
- 「时间轴」默认 0.00,非编辑态显示与双光标差值同一套复合单位文本(`Δt:500ms`、`Δt:1h 1min 2s`);
  进入编辑态时显示以**秒**为单位的数值,并在输入框最右侧提示单位 `s`。
- 「偏移调整」的还原图标把两个偏移恢复为默认值 0;「系数调整」的还原图标仍只重置系数。
- 面板/输入框的编辑初值与显示文本分离(`MenuItem::editValue` / `editSuffix`),
  非编辑态(`Δt:…`)与编辑态(`3662`)不一致时也能正确回显。

语义与结算顺序:
- 结算顺序变为 **系数 → 绝对值 → 镜像 → 偏移**(前三步不变)。
- Y 偏移:给绘制值加常数(波形上下移动);时间偏移:采样绘制在 `t + offsetTime` 处
  (例如 t=1s、偏移 -0.5s → 画在 0.5s)。
- ⚠️ Y 偏移以**未镜像空间**为准:垂直镜像开关变化时同步取反偏移量,
  使绘制等价于「先加上偏移再镜像」(开关来回切换可逆)。
- 绘制链路全部跟随偏移:`BuildPolyline`(逐点描边 / 像素列包络 / 跨列边界插值 / 粗粒度分组层,
  保序的 Y 偏移直接加在分组极值上)、派生预览曲线、光标取值读数(含 Δt)、
  可见区间 Y 轴自适应、整段自适应的区间映射;折线缓存键、场景指纹、样式快照与图层指纹都纳入两个偏移。

派生信号(实验性功能「构建为新信号」):
- 修复「预览是调整后的波形、构建出来却按原始状态绘制」:新信号**继承源信号的调整变换**
  (系数/绝对值/两个镜像/两个偏移)——网格预览绘制的是 `变换(滤波结果)`,
  而构建出的信号只存了滤波结果,因此必须把变换一并继承;
  时间轴偏移**只能**靠继承(派生信号与源信号共用同一文件时间轴,无法把时间平移烘进数据)。

离屏验证(不干扰用户):
- ⚠️ harness 现在在每次注入鼠标消息后**立即释放本窗口的鼠标捕获**
  (宿主 WM_LBUTTONDOWN 会 SetCapture,隐藏窗口同样会把用户的真实鼠标输入吸走);
  后续消息本来就是直接投递,无需捕获。
- 新增 [AP]:按钮标签不裁切、面板 5 行顺序与「偏移调整」位于系数输入框下方、
  Y轴/时间轴行的标签与输入框同行且默认值/单位提示正确、时间轴编辑态显示秒值并在最右侧提示 `s`、
  Y 偏移提交/显示正负号/波形整体平移量、时间偏移提交/Δt 复合单位(含 3662 → Δt:1h 1min 2s)/
  编辑态为秒、**按"偏移若干个采样点"构造的精确几何验证**(采样被画到移位后的像素位置、且不再画在原处)、
  读数跟随、两个还原图标、垂直镜像对 Y 偏移取反(可逆)、
  以及派生信号继承变换;[AL] 补充「系数→绝对值→镜像→偏移」的顺序与区间映射(含 Y 偏移)。
- 合计 645/645 通过;make clean && make all 零警告。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-10-13 17:56:00 +08:00
1 parent 2b02518f85
commit fc77b0262f
11 files changed
+567 -252

No files matched your search

+9 -2
View File
@@ -1443,9 +1443,11 @@ namespace jdi
* @param [IN] count 可用采样点数。
* @param [OUT] begin 区间起点(含)。
* @param [OUT] end 区间终点(不含)。
* @param [IN] timeShift 信号的时间偏移:采样画在 `t + timeShift` 处,因此可见采样对应的
* 源时刻区间为 `[ViewMin - timeShift, ViewMax - timeShift]`(偏移 0 时同视界)。
*/
void VisibleIndexRange(const DataFile& file, std::size_t count, std::size_t& begin,
std::size_t& end) const;
std::size_t& end, double timeShift = 0.0) const;
/**
* @brief 绘制某条信号的多次迭代均值滤波曲线(与中心平均值曲线同一配色派生规则)。
@@ -1842,6 +1844,8 @@ namespace jdi
bool absolute = false; /**< 绘制前是否取绝对值。 */
bool mirrorVertical = false; /**< 绘制前是否按 Y=0 上下镜像。 */
bool mirrorHorizontal = false; /**< 是否水平镜像(取值下标反序)。 */
float offsetY = 0.0f; /**< Y 轴偏移。 */
double offsetTime = 0.0; /**< 时间轴偏移(秒)。 */
std::vector<gd::PointF> points; /**< 折线顶点。 */
std::vector<std::size_t> breaks; /**< 每段折线在 points 中的起始下标。 */
@@ -2009,6 +2013,8 @@ namespace jdi
bool absolute = false; /**< 是否取绝对值。 */
bool mirrorVertical = false; /**< 是否按 Y=0 上下镜像。 */
bool mirrorHorizontal = false; /**< 是否水平镜像(数据反序)。 */
float offsetY = 0.0f; /**< Y 轴偏移。 */
double offsetTime = 0.0; /**< 时间轴偏移(秒)。 */
/** @brief 相等比较(用于判断"逐信号外观样式是否变化")。 */
bool operator==(const SceneStyleEntry& other) const
@@ -2017,7 +2023,8 @@ namespace jdi
lineWidth == other.lineWidth && mode == other.mode &&
gain == other.gain && absolute == other.absolute &&
mirrorVertical == other.mirrorVertical &&
mirrorHorizontal == other.mirrorHorizontal;
mirrorHorizontal == other.mirrorHorizontal && offsetY == other.offsetY &&
offsetTime == other.offsetTime;
}
};
+62 -177
View File
@@ -501,143 +501,6 @@ namespace jdi
}
}
/**
* @brief 时间间隔的显示单位。
*/
struct DurationUnit
{
double scale = 1.0; /**< 1 个该单位等于多少秒。 */
const wchar_t* name = L"s"; /**< 单位名称。 */
};
/**
* @brief 把时间间隔格式化为"Δt 语义"的复合单位文本。
*
* 规则(用户明确要求):
* - 顶级单位高于 s(即 h / min)时**一路整合到 s**,例如 3662s → "1h 1min 2s";
* - 顶级单位为 s 及以下时,若不是该单位的整数,则**向下一级递进**,
* 例如 5.06s → "5s 60ms";整秒数不加下级单位,例如 5s → "5s";
* - 顶级单位为 ps 时**不再递进次级单位**,例如 5.123ps → "5.123 ps"。
*
* @param [IN] seconds 时间间隔(秒)。
* @return 不含 "Δt" 前缀的间隔文本。
*/
std::wstring FormatDurationCompound(double seconds)
{
if (!std::isfinite(seconds))
{
return L"—";
}
const double value = std::fabs(seconds);
if (value <= 0.0)
{
return L"0s";
}
// ---- 顶级单位高于 s(h / min):整合为 h/min/s(整数秒)----
// 注意条件必须是 >= 60s:1~60s 属于"顶级单位为 s"的情况,需按下一条规则处理
// (非整数秒要递进到 ms,例如 5.06s → "5s 60ms")。
if (value >= 60.0)
{
const long long totalSeconds = static_cast<long long>(std::llround(value));
long long hours = 0;
long long minutes = 0;
long long secs = totalSeconds;
if (totalSeconds >= 3600)
{
hours = totalSeconds / 3600;
secs = totalSeconds % 3600;
}
if (secs >= 60)
{
minutes = secs / 60;
secs = secs % 60;
}
std::wstring text;
const auto append = [&text](long long amount, const wchar_t* unit)
{
if (amount <= 0)
{
return;
}
if (!text.empty())
{
text += L" ";
}
text += std::to_wstring(amount);
text += unit;
};
append(hours, L"h");
append(minutes, L"min");
append(secs, L"s");
if (!text.empty())
{
return text;
}
}
// ---- 顶级单位为 s:整数秒 + 可选 ms ----
if (value >= 1.0)
{
const double whole = std::floor(value);
const double fraction = value - whole;
std::wstring text = FormatValue(whole) + L"s";
const double remainderMs = fraction * 1000.0;
if (remainderMs >= 1.0e-6)
{
text += L" ";
text += FormatValue(remainderMs);
text += L"ms";
}
return text;
}
// ---- 顶级单位低于 s:ms / us / ns / ps ----
const DurationUnit subUnits[] = {
{1.0e-3, L"ms"}, {1.0e-6, L"us"}, {1.0e-9, L"ns"}, {1.0e-12, L"ps"},
};
const std::size_t subCount = sizeof(subUnits) / sizeof(subUnits[0]);
std::size_t index = subCount - 1;
for (std::size_t i = 0; i < subCount; ++i)
{
if (value >= subUnits[i].scale)
{
index = i;
break;
}
}
// 顶级单位为 ps:不再递进次级单位(保留小数)。
if (index + 1 >= subCount)
{
return FormatValue(value / subUnits[index].scale) + L" " + subUnits[index].name;
}
const double scaled = value / subUnits[index].scale;
double whole = std::floor(scaled);
double fraction = scaled - whole;
// 浮点误差可能导致 fraction 极接近 1(例如 4.9999999),归一化一下。
if (fraction >= 1.0 - 1.0e-9)
{
whole += 1.0;
fraction = 0.0;
}
std::wstring text = FormatValue(whole);
text += subUnits[index].name;
// 仍是该单位的整数 → 不显示下级单位。
const double remainder = fraction * subUnits[index].scale;
const double inNext = remainder / subUnits[index + 1].scale;
if (inNext >= 1.0e-6)
{
text += L" ";
text += FormatValue(inNext);
text += subUnits[index + 1].name;
}
return text;
}
/**
* @brief 双光标差值的完整显示文本("Δt:" + 复合单位间隔)。
* @param [IN] seconds 时间间隔(秒)。
@@ -662,23 +525,6 @@ namespace jdi
return FormatValue(seconds) + L"s";
}
/**
* @brief 把时间间隔格式化为"秒"数值文本(不含单位),用于内联编辑。
*
* 编辑时的量级**始终是秒**:即使显示为 "1.5min",双击编辑的内容也是 90。
*
* @param [IN] seconds 时间间隔(秒)。
* @return 形如 "60" 的文本。
*/
std::wstring FormatDurationSeconds(double seconds)
{
if (!std::isfinite(seconds))
{
return L"0";
}
return FormatValue(std::fabs(seconds));
}
/**
* @brief 按比例缩放颜色的 alpha 通道(用于提示浮层淡出)。
* @param [IN] argb 原始 0xAARRGGBB 颜色。
@@ -2204,7 +2050,7 @@ namespace jdi
}
void PlotView::VisibleIndexRange(const DataFile& file, std::size_t count, std::size_t& begin,
std::size_t& end) const
std::size_t& end, double timeShift) const
{
begin = 0;
end = count;
@@ -2212,15 +2058,16 @@ namespace jdi
{
return;
}
// 时间偏移:采样画在 `t + timeShift` 处,因此落在当前视界内的采样其源时刻区间要整体左移。
const std::vector<double>& time = file.time;
begin = static_cast<std::size_t>(
std::lower_bound(time.begin(), time.end(), Tab().ViewMin()) - time.begin());
std::lower_bound(time.begin(), time.end(), Tab().ViewMin() - timeShift) - time.begin());
if (begin > 0)
{
--begin;
}
end = static_cast<std::size_t>(
std::lower_bound(time.begin(), time.end(), Tab().ViewMax()) - time.begin());
std::lower_bound(time.begin(), time.end(), Tab().ViewMax() - timeShift) - time.begin());
if (end < count)
{
++end;
@@ -3202,19 +3049,24 @@ namespace jdi
}
const std::size_t count = std::min(file->time.size(), signal->values.size());
const SignalStyle* style = m_dataSet->Style(ref);
// 时间偏移:可见采样对应的源时刻区间要整体左移(画在 t + shift 处)。
const double timeShift = (style != nullptr) ? style->offsetTime : 0.0;
const double sourceMin = viewMin - timeShift;
const double sourceMax = viewMax - timeShift;
std::size_t begin = 0;
std::size_t end = count;
if (file->timeMonotonic)
{
// 时间单调时直接二分定位区间,百万级采样也只需扫过可见段。
begin = static_cast<std::size_t>(
std::lower_bound(file->time.begin(), file->time.end(), viewMin) - file->time.begin());
std::lower_bound(file->time.begin(), file->time.end(), sourceMin) - file->time.begin());
if (begin > 0)
{
--begin;
}
end = static_cast<std::size_t>(
std::lower_bound(file->time.begin(), file->time.end(), viewMax) - file->time.begin());
std::lower_bound(file->time.begin(), file->time.end(), sourceMax) - file->time.begin());
if (end < count)
{
++end;
@@ -3226,7 +3078,7 @@ namespace jdi
if (!file->timeMonotonic)
{
const double t = file->time[index];
if (!std::isfinite(t) || t < viewMin || t > viewMax)
if (!std::isfinite(t) || t < sourceMin || t > sourceMax)
{
continue;
}
@@ -5355,11 +5207,13 @@ namespace jdi
const std::size_t sampleCount = std::min(resolved.file->time.size(), resolved.data->values.size());
const double viewMin = Tab().ViewMin();
const double viewMax = Tab().ViewMax();
// 绘制前的数值变换(系数/绝对值/镜像)会改变顶点,必须进缓存键。
// 绘制前的数值变换(系数/绝对值/镜像/偏移)会改变顶点,必须进缓存键。
const float gain = (resolved.style != nullptr) ? resolved.style->gain : 1.0f;
const bool absolute = (resolved.style != nullptr) && resolved.style->absolute;
const bool mirrorVertical = (resolved.style != nullptr) && resolved.style->mirrorVertical;
const bool mirrorHorizontal = (resolved.style != nullptr) && resolved.style->mirrorHorizontal;
const float offsetY = (resolved.style != nullptr) ? resolved.style->offsetY : 0.0f;
const double offsetTime = (resolved.style != nullptr) ? resolved.style->offsetTime : 0.0;
for (const PolylineCacheEntry& entry : m_polylineCache)
{
@@ -5368,7 +5222,8 @@ namespace jdi
entry.plotWidth == m_plotRect.Width && entry.laneY == lane.rect.Y &&
entry.laneHeight == lane.rect.Height && entry.laneYMin == lane.yMin &&
entry.laneYMax == lane.yMax && entry.gain == gain && entry.absolute == absolute &&
entry.mirrorVertical == mirrorVertical && entry.mirrorHorizontal == mirrorHorizontal)
entry.mirrorVertical == mirrorVertical && entry.mirrorHorizontal == mirrorHorizontal &&
entry.offsetY == offsetY && entry.offsetTime == offsetTime)
{
return &entry;
}
@@ -5397,6 +5252,8 @@ namespace jdi
entry.absolute = absolute;
entry.mirrorVertical = mirrorVertical;
entry.mirrorHorizontal = mirrorHorizontal;
entry.offsetY = offsetY;
entry.offsetTime = offsetTime;
BuildPolyline(resolved, lane, entry.points, entry.breaks, entry.band, entry.bandBreaks,
entry.bandMode, entry.bandSpiky);
@@ -5432,18 +5289,24 @@ namespace jdi
const double viewMin = Tab().ViewMin();
const double viewMax = Tab().ViewMax();
// 时间偏移:采样画在 `t + offsetTime` 处,因此“落在当前视界内的采样”
// 其源时刻位于 `[viewMin - offsetTime, viewMax - offsetTime]`。
const double timeShift = resolved.style->offsetTime;
const double sourceMin = viewMin - timeShift;
const double sourceMax = viewMax - timeShift;
std::size_t begin = 0;
std::size_t end = count;
if (resolved.file->timeMonotonic)
{
const std::vector<double>::const_iterator first = std::lower_bound(time.begin(), time.end(), viewMin);
const std::vector<double>::const_iterator first = std::lower_bound(time.begin(), time.end(), sourceMin);
begin = static_cast<std::size_t>(first - time.begin());
if (begin > 0)
{
--begin;
}
const std::vector<double>::const_iterator last = std::lower_bound(time.begin(), time.end(), viewMax);
const std::vector<double>::const_iterator last = std::lower_bound(time.begin(), time.end(), sourceMax);
end = static_cast<std::size_t>(last - time.begin());
if (end < count)
{
@@ -5497,8 +5360,9 @@ namespace jdi
{
const float marginPx =
std::max(4.0f, std::max(1.0f, resolved.style->lineWidth) * m_scale + 3.0f);
const double ta = XToTime(m_stripX0 - marginPx);
const double tb = XToTime(m_stripX1 + marginPx);
// 条带边界是**绘制时刻**,换算成源时刻要减去时间偏移。
const double ta = XToTime(m_stripX0 - marginPx) - timeShift;
const double tb = XToTime(m_stripX1 + marginPx) - timeShift;
const double clipLo = std::min(ta, tb);
const double clipHi = std::max(ta, tb);
std::size_t sBegin = static_cast<std::size_t>(
@@ -5546,13 +5410,16 @@ 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;
// ⚠️ 粗粒度分组只存了逐组的 min/max:取绝对值 / 中心反转会把两者互换,
// ⚠️ 粗粒度分组只存了逐组的 min/max:取绝对值 / 垂直镜像会把两者互换,
// 此时分组层无法再给出正确的包络,必须回退到逐采样点变换(见 `DrawTransformOrderPreserving`)。
// Y 偏移是保序的(整体加常数),因此仍可用分组层 —— 只需把偏移一并加上。
const bool hasCoarse =
(coarseStride > 0) && !coarseMin.empty() && (coarseMin.size() == coarseMax.size()) &&
DrawTransformOrderPreserving(*resolved.style);
// 保序变换(正增益)下,分组极值直接乘增益即为变换后的极值(gain==1 时逐位不变)。
// 保序变换(正系数 + Y 偏移)下:分组极值 × 系数 + 偏移 即为变换后的极值
//(系数 1、偏移 0 时逐位不变)。
const double coarseScale = static_cast<double>(resolved.style->gain);
const double coarseOffset = static_cast<double>(resolved.style->offsetY);
bool inSegment = false;
@@ -5575,21 +5442,24 @@ namespace jdi
std::size_t searchFrom = begin;
for (std::size_t column = 0; column < columns; ++column)
{
// `t0/t1` 是**绘制时刻**的列边界;源时刻要减去时间偏移(偏移为 0 时二者相同)。
const double t0 = viewMin + viewSpan * (static_cast<double>(column) / static_cast<double>(columns));
const double t1 =
viewMin + viewSpan * (static_cast<double>(column + 1) / static_cast<double>(columns));
const double s0 = t0 - timeShift;
const double s1 = t1 - timeShift;
// 列边界随 column 单调递增,从上一列的位置继续二分,
// 把每次搜索的范围压到剩余区间,避免 2400 次全区间二分带来的缓存缺失。
const std::size_t i0 = static_cast<std::size_t>(
std::lower_bound(time.begin() + static_cast<std::ptrdiff_t>(searchFrom), rangeEnd, t0) -
std::lower_bound(time.begin() + static_cast<std::ptrdiff_t>(searchFrom), rangeEnd, s0) -
time.begin());
searchFrom = i0;
const std::size_t i1 = (column + 1 == columns)
? end
: static_cast<std::size_t>(
std::lower_bound(time.begin() + static_cast<std::ptrdiff_t>(i0),
rangeEnd, t1) -
rangeEnd, s1) -
time.begin());
double low = std::numeric_limits<double>::infinity();
@@ -5611,8 +5481,8 @@ namespace jdi
std::min(srcB / coarseStride, coarseMin.size() - 1);
for (std::size_t bucket = firstBucket; bucket <= lastBucket; ++bucket)
{
const double bucketLow = coarseMin[bucket] * coarseScale;
const double bucketHigh = coarseMax[bucket] * coarseScale;
const double bucketLow = coarseMin[bucket] * coarseScale + coarseOffset;
const double bucketHigh = coarseMax[bucket] * coarseScale + coarseOffset;
if (!std::isfinite(bucketLow))
{
// 该分组全部是无效值:包络到此断开,后续另起一段。
@@ -5667,7 +5537,7 @@ namespace jdi
// 其极值必然出现在采样点或列边界上,因此「列内采样点 + 两侧边界取值」就是
// 该列包络的精确解,且相邻两列在边界处取到同一个值,包络带严丝合缝。
double boundary = 0.0;
if (PolylineValueAt(time, values, count, *resolved.style, i0, t0, boundary))
if (PolylineValueAt(time, values, count, *resolved.style, i0, s0, boundary))
{
boundary = ApplyDrawTransform(*resolved.style, boundary);
if (boundary < low)
@@ -5681,7 +5551,7 @@ namespace jdi
highOrder = (i0 > 0) ? SourceIndexFor(*resolved.style, count, i0 - 1) : 0;
}
}
if (PolylineValueAt(time, values, count, *resolved.style, i1, t1, boundary))
if (PolylineValueAt(time, values, count, *resolved.style, i1, s1, boundary))
{
boundary = ApplyDrawTransform(*resolved.style, boundary);
const std::size_t order = SourceIndexFor(*resolved.style, count, (i1 > 0) ? (i1 - 1) : 0);
@@ -5779,7 +5649,8 @@ namespace jdi
continue;
}
const float x = TimeToX(t);
// 时间偏移:该采样画在 `t + offsetTime` 处(例如 t=1s、偏移 -0.5s → 画在 0.5s)。
const float x = TimeToX(t + timeShift);
float y = ValueToY(value, lane);
y = static_cast<float>(ClampDouble(y, limitTop, limitBottom));
@@ -6441,6 +6312,9 @@ namespace jdi
mix(resolved.style->absolute ? 1ull : 0ull);
mix(resolved.style->mirrorVertical ? 1ull : 0ull);
mix(resolved.style->mirrorHorizontal ? 1ull : 0ull);
// 偏移(Y 平移 + 时间平移)同样会改变波形。
mixFloat(resolved.style->offsetY);
mixFloat(static_cast<float>(resolved.style->offsetTime));
}
// 图例文案参与绘制,重命名必须让缓存失效。
mixText(resolved.data->Title());
@@ -6490,6 +6364,8 @@ namespace jdi
entry.absolute = resolved.style->absolute;
entry.mirrorVertical = resolved.style->mirrorVertical;
entry.mirrorHorizontal = resolved.style->mirrorHorizontal;
entry.offsetY = resolved.style->offsetY;
entry.offsetTime = resolved.style->offsetTime;
}
lane.push_back(entry);
}
@@ -7348,6 +7224,14 @@ namespace jdi
mixFloat(resolved.style->opacity);
mixFloat(resolved.style->lineWidth);
mix(static_cast<std::uint64_t>(resolved.style->mode));
// 「调整」变换会改变图层里的波形,也必须让图层失效
//(否则贴回的旧像素会与当前样式不一致)。
mixFloat(resolved.style->gain);
mix(resolved.style->absolute ? 1ull : 0ull);
mix(resolved.style->mirrorVertical ? 1ull : 0ull);
mix(resolved.style->mirrorHorizontal ? 1ull : 0ull);
mixFloat(resolved.style->offsetY);
mixFloat(static_cast<float>(resolved.style->offsetTime));
}
return hash;
}
@@ -10331,7 +10215,7 @@ namespace jdi
std::size_t begin = 0;
std::size_t end = 0;
VisibleIndexRange(*resolved.file, count, begin, end);
VisibleIndexRange(*resolved.file, count, begin, end, resolved.style->offsetTime);
if (end <= begin)
{
return;
@@ -10387,7 +10271,8 @@ namespace jdi
}
float y = ValueToY(ApplyDrawTransform(*resolved.style, static_cast<double>(sampled)), lane);
y = static_cast<float>(ClampDouble(y, limitTop, limitBottom));
const float x = TimeToX(time[index]);
// 时间偏移:该采样画在 `t + offsetTime` 处。
const float x = TimeToX(time[index] + resolved.style->offsetTime);
if (!inSegment)
{
breaks.push_back(points.size());