diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 12678f4..ab297d5 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -344,22 +344,26 @@ namespace jdi * * ⚠️ 额外要求视界内**至少有一个可见数据点**:数据点全在视界之外的信号既没有可吸附 * 的对象、也没有可提示的内容(否则它会被判成“0 < N×4 稀疏”而白切一次「点线」)。 + * ⚠️ 可见性一律按**显示时间**(`采样时刻 + timeShift`)判断 —— `timeShift` 就是该信号的 + * `SignalStyle::offsetTime`(存在时间轴偏移时不能拿源时刻去比视界)。 * * @param [IN] axis 该信号使用的时间轴。 * @param [IN] signal 目标信号。 + * @param [IN] timeShift 该信号的显示时间偏移(`offsetTime`)。 * @return 开启吸附返回 true。 */ - bool SignalSnapEnabled(const TimeAxis& axis, const SignalData& signal) const; + bool SignalSnapEnabled(const TimeAxis& axis, const SignalData& signal, double timeShift) const; /** * @brief 判断某信号在给定可见下标区间内是否存在**落在视界内**的有效采样点。 * @param [IN] axis 该信号使用的时间轴。 * @param [IN] signal 目标信号。 * @param [IN] begin 可见下标区间起点。 @param [IN] end 终点(不含)。 + * @param [IN] timeShift 该信号的显示时间偏移(`offsetTime`)。 * @return 存在返回 true。 */ bool HasVisibleSample(const TimeAxis& axis, const SignalData& signal, std::size_t begin, - std::size_t end) const; + std::size_t end, double timeShift) const; /** * @brief 找某信号**可见区间内**离给定时刻最近的有效采样点。 @@ -367,24 +371,30 @@ namespace jdi * 稀疏信号(有效点已缓存)用二分;一般信号从最近下标向两侧**有界**探测 *(NaN 只出现在缺少数据的区段)。视界之外的采样点不是合法落点,一律排除。 * + * ⚠️ `time` 与输出的 `candidate` 都是**显示时间**(画面上的时刻):内部按 `timeShift` + * 换算回源空间查找、再把结果加回去,因此存在「调整 → 时间轴偏移」时也能吸到 + * **画面上**的数据点,而不是历史位置。 + * * @param [IN] axis 该信号使用的时间轴。 * @param [IN] signal 目标信号。 - * @param [IN] time 目标时刻。 - * @param [OUT] candidate 最近的可见有效采样点时刻。 + * @param [IN] time 目标时刻(显示时间)。 + * @param [IN] timeShift 该信号的显示时间偏移(`offsetTime`)。 + * @param [OUT] candidate 最近的可见有效采样点时刻(显示时间)。 * @return 找到返回 true。 */ bool NearestSnapSample(const TimeAxis& axis, const SignalData& signal, double time, - double& candidate) const; + double timeShift, double& candidate) const; /** * @brief 取某时刻附近的**局部采样间距**(时间单位,用于限制吸附半径)。 * @param [IN] axis 该信号使用的时间轴。 * @param [IN] signal 目标信号。 - * @param [IN] time 目标时刻。 + * @param [IN] time 目标时刻(显示时间)。 + * @param [IN] timeShift 该信号的显示时间偏移(`offsetTime`)。 * @param [OUT] spacing 相邻采样点的时间间隔(无法判定时为 0)。 */ void LocalSampleSpacing(const TimeAxis& axis, const SignalData& signal, double time, - double& spacing) const; + double timeShift, double& spacing) const; /** * @brief 把时间吸附到距今最近的数据采样点。 diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index f09f498..7a98ce4 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -1324,6 +1324,15 @@ namespace jdi return; } + // 「仅有一个网格、且网格内只显示了一个**单点**信号」时,适应效果必须与 + // “首次加载并勾选该单点信号”时完全一致(数据点居中、时间跨度 1s、Y 跨度 1)。 + // 否则整段自适应会让时间轴覆盖整条时间列 —— 数据点被挤到网格边缘、看起来“飞了” + //(用户 2026-10-15 报告)。 + if (FitSinglePointSignal()) + { + return; + } + double low = 0.0; double high = 1.0; TimeBounds(low, high); @@ -1836,7 +1845,8 @@ namespace jdi return std::max(1, static_cast(std::llround(cells))); } - bool PlotView::SignalSnapEnabled(const TimeAxis& axis, const SignalData& signal) const + bool PlotView::SignalSnapEnabled(const TimeAxis& axis, const SignalData& signal, + double timeShift) const { const int cells = GridMinorCellCount(); if (cells <= 0 || signal.finiteCount == 0) @@ -1857,7 +1867,7 @@ namespace jdi } std::size_t begin = 0; std::size_t end = 0; - VisibleIndexRange(axis, count, begin, end); + VisibleIndexRange(axis, count, begin, end, timeShift); if (end <= begin) { return false; @@ -1869,7 +1879,7 @@ namespace jdi if (signal.finiteCount < threshold) { // 有效点总数本来就不足阈值 ⇒ 只需确认视界内确实有点(稀疏点已缓存时二分判定)。 - return HasVisibleSample(axis, signal, begin, end); + return HasVisibleSample(axis, signal, begin, end, timeShift); } // 一般信号:统计**当前视界内**的有效点数量(攒够阈值立刻早退,代价有界)。 @@ -1883,8 +1893,15 @@ namespace jdi { continue; } - if (!std::isfinite(axis.time[index]) || axis.time[index] < Tab().ViewMin() || - axis.time[index] > Tab().ViewMax()) + // ⚠️ 必须按**绘制时刻**判断可见性(`t + offsetTime`),否则存在时间轴偏移时 + // 会把“已经移出视界”的采样点算进来(用户报告吸附到历史位置)。 + const double time = axis.time[index]; + if (!std::isfinite(time)) + { + continue; + } + const double displayTime = time + timeShift; + if (displayTime < Tab().ViewMin() || displayTime > Tab().ViewMax()) { continue; } @@ -1897,12 +1914,12 @@ namespace jdi } bool PlotView::HasVisibleSample(const TimeAxis& axis, const SignalData& signal, std::size_t begin, - std::size_t end) const + std::size_t end, double timeShift) const { - const double viewMin = Tab().ViewMin(); - const double viewMax = Tab().ViewMax(); + const double viewMin = Tab().ViewMin() - timeShift; + const double viewMax = Tab().ViewMax() - timeShift; - // 稀疏信号(有效点已缓存且升序):二分即可。 + // 稀疏信号(有效点已缓存且升序):二分即可(整体平移不改变有序性)。 if (!signal.sparseTimes.empty()) { const std::vector& times = signal.sparseTimes; @@ -1925,21 +1942,25 @@ namespace jdi } bool PlotView::NearestSnapSample(const TimeAxis& axis, const SignalData& signal, double time, - double& candidate) const + double timeShift, double& candidate) const { - const double viewMin = Tab().ViewMin(); - const double viewMax = Tab().ViewMax(); + // ⚠️ `time` / `candidate` 都是**显示时间**(画面上的时刻):采样点 k 画在 `time[k] + timeShift` + // 处,而光标存的也是显示时间,因此查找与可见性判定都要整体平移(否则存在 + // 「调整 → 时间轴偏移」时吸附会落在历史位置上,用户 2026-10-15 报告)。 + const double viewMin = Tab().ViewMin() - timeShift; + const double viewMax = Tab().ViewMax() - timeShift; // 稀疏信号:有效点已在加载时缓存(按时间升序)⇒ 二分取最近的一个。 if (!signal.sparseTimes.empty()) { const std::vector& times = signal.sparseTimes; - const std::vector::const_iterator it = std::lower_bound(times.begin(), times.end(), time); + const double target = time - timeShift; + const std::vector::const_iterator it = std::lower_bound(times.begin(), times.end(), target); bool found = false; double bestDistance = std::numeric_limits::infinity(); for (int side = 0; side < 2; ++side) { - // side 0 = 第一个 >= time 的点;side 1 = 它前面的那个点。 + // side 0 = 第一个 >= target 的点;side 1 = 它前面的那个点。 if (side == 1 && it == times.begin()) { continue; @@ -1954,11 +1975,11 @@ namespace jdi { continue; } - const double distance = std::fabs(value - time); + const double distance = std::fabs((value + timeShift) - time); if (distance < bestDistance) { bestDistance = distance; - candidate = value; + candidate = value + timeShift; found = true; } } @@ -1972,7 +1993,7 @@ namespace jdi { return false; } - const int center = FindNearestSample(axis, time); + const int center = FindNearestSample(axis, time - timeShift); if (center < 0) { return false; @@ -1997,13 +2018,13 @@ namespace jdi { continue; } - // 只接受**可见区间内**的采样点:视界之外的采样点不是合法的落点 + // 只接受**可见区间内**的采样点(按绘制时刻判断):视界之外的采样点不是合法落点 //(否则吸附会把光标一把甩到视界边缘并钉住)。 if (sampleTime < viewMin || sampleTime > viewMax) { continue; } - candidate = sampleTime; + candidate = sampleTime + timeShift; return true; } } @@ -2011,14 +2032,16 @@ namespace jdi } void PlotView::LocalSampleSpacing(const TimeAxis& axis, const SignalData& signal, double time, - double& spacing) const + double timeShift, double& spacing) const { spacing = 0.0; + // `time` 是**显示时间**,而缓存/采样数组是源时间 ⇒ 查询统一换算回源空间。 + const double target = time - timeShift; if (signal.sparseTimes.size() >= 2) { const std::vector& times = signal.sparseTimes; const std::size_t found = - static_cast(std::lower_bound(times.begin(), times.end(), time) - times.begin()); + static_cast(std::lower_bound(times.begin(), times.end(), target) - times.begin()); const std::size_t left = (found > 0) ? (found - 1) : 0; const std::size_t right = std::min(times.size() - 1, left + 1); if (right > left) @@ -2045,7 +2068,7 @@ namespace jdi { return; } - const int index = FindNearestSample(axis, time); + const int index = FindNearestSample(axis, target); if (index < 0) { return; @@ -2113,18 +2136,24 @@ namespace jdi } const SignalData* signal = m_dataSet->Resolve(ref); const TimeAxis* axis = m_dataSet->Axis(ref); + const SignalStyle* style = m_dataSet->Style(ref); if (signal == nullptr || axis == nullptr) { continue; } + // ⚠️ 一切吸附判定与查找都在**显示时间**空间进行:采样点 k 画在 + // `time[k] + offsetTime` 处,而光标存的也是显示时间。 + // 少了这一步,信号一旦设置过「调整 → 时间轴偏移」,吸附就会落在**历史位置** + // (用户 2026-10-15 报告“存在时间轴偏移调整时无法吸附到正确位置”)。 + const double timeShift = (style != nullptr) ? style->offsetTime : 0.0; // 只有“一屏内数据点少于 N×4”的信号参与吸附;判据随视界实时重算, // 因此疏密变化的信号在稀疏区自动吸附、移到稠密区自动恢复自由移动。 - if (!SignalSnapEnabled(*axis, *signal)) + if (!SignalSnapEnabled(*axis, *signal, timeShift)) { continue; } double candidate = 0.0; - if (!NearestSnapSample(*axis, *signal, time, candidate)) + if (!NearestSnapSample(*axis, *signal, time, timeShift, candidate)) { continue; } @@ -2132,7 +2161,7 @@ namespace jdi // 吸附半径再受“半个局部采样间距”限制(点很稀疏时不被吸死)。 double limitTime = radiusTime; double spacingTime = 0.0; - LocalSampleSpacing(*axis, *signal, candidate, spacingTime); + LocalSampleSpacing(*axis, *signal, candidate, timeShift, spacingTime); if (spacingTime > 0.0) { limitTime = std::min(limitTime, spacingTime * 0.5); @@ -4338,23 +4367,11 @@ namespace jdi return false; } - // 与自动自适应保持一致的上下留白,避免波形顶到网格边界。 - if (high > low) - { - const double pad = (high - low) * 0.05; - low -= pad; - high += pad; - } - else - { - // ⚠️ 可见区间内只有一个取值(单点信号 / 恒定值):给一个**可读**的跨度, - // 而不是几乎为 0 的退化区间 —— 退化区间会让幅值网格线挤成一团、 - // 数据点也看不出位置(用户报告“单值信号的全局自适应不生效”)。 - // 这里的留白与 `TabConfig::ComputeLaneRange` 的 `PadRange` 完全一致。 - const double magnitude = std::max(1.0, std::fabs(low) * 0.1); - low -= magnitude; - high += magnitude; - } + // ⚠️ 留白必须与整段自适应(`TabConfig::ComputeLaneRange` → `PadRange`)**共用同一规则** + //(`PadValueRange`):否则「点过适应视图后再打开 Y 轴自适应」会因为留白不同 + //(6% vs 5%)而算出不一样的区间,网格被无意义地重绘一次; + // 反向(关掉 Y 轴自适应再点适应视图)同样会变。 + PadValueRange(low, high); Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; lane.autoRange = false; @@ -4452,7 +4469,8 @@ namespace jdi // 判定与**光标吸附完全同源**(用户 2026-10-15 明确要求): // 只有“一屏内数据点少于 N×4”的信号才自动切「点线」, // 不再满足时(适应视图、或把视界移到信号的稠密区)自动恢复原样式。 - if (SignalSnapEnabled(*axis, *data)) + // ⚠️ 可见性同样按**显示时间**判断(含 `offsetTime`),与吸附保持一致。 + if (SignalSnapEnabled(*axis, *data, style->offsetTime)) { if (!style->autoScatterActive) { diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index 5cb4b5b..00d50dd 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -1620,16 +1620,18 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( - **N = `GridMinorCellCount()`**:网格底衬一横排的小格数,与 `PaintLane`/`PaintTimeAxis` 用**同一套** `NiceStep(span, kTimeTickTarget)` + 五等分算出(所以不随缩放等级大幅变化)。 -- **`SignalSnapEnabled(axis, signal)`**(用户 2026-10-15 的定义): +- **`SignalSnapEnabled(axis, signal, timeShift)`**(用户 2026-10-15 的定义): **当且仅当**该信号在**当前一屏**内的数据点个数 `< N × 4` 时开启吸附; 判据随视界实时重算 ⇒ 疏密变化的信号“移到稀疏区自动吸附、移回稠密区自动自由移动”。 - ⚠️ 额外要求视界内**至少有一个可见数据点**(否则“0 < N×4”会把“数据全在视界之外”的信号 - 误判成稀疏,白切一次点线且没有任何可吸附对象)。 + ⚠️ 可见性与点个数一律按**显示时间**(`采样时刻 + timeShift`,`timeShift` = 该信号的 + `offsetTime`)判断;且要求视界内**至少有一个可见数据点**(否则“0 < N×4”会把“数据全在 + 视界之外”的信号误判成稀疏,白切一次点线且没有任何可吸附对象)。 ⚠️ 性能:总数不足阈值时直接返回(配合 `sparseTimes` 二分);否则只统计可见下标区间内的 有限值并**攒够阈值即早退**,扫描窗口还有 `threshold × 64` 的上限(防 NaN 长循环)。 - **`SnapTimeToSample`**:判定**只针对鼠标所在网格**(`LaneAtY(m_mouseY)`)—— 同一个时间轴上,一个网格达到稀疏标准、另一个没有时,只在前者吸附; 同网格内**所有符合判定的信号**的数据点都参与比较。 + ⚠️ 一切判定与查找都在**显示时间**(`采样时刻 + offsetTime`)空间进行,详见 §9.31。 **吸附半径 = 一个网格小格的宽度**(与判定同源),且**不超过半个局部采样间距** ⇒ 数据点很稀疏(一屏只有一两个点)时光标在点之间仍可自由移动(吸附 ≠ 卡住)。 ⚠️ `LocalSampleSpacing` 对 `finiteCount <= 1` 必须返回 0(“数据点间距无定义”), @@ -1662,6 +1664,49 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( --- +### 9.31 自适应范围/时机澄清 + 适应视图与 Y 轴自适应互不干扰 + 吸附跟随时间偏移(本轮,起源设备) + +> 本轮四项(用户 2026-10-15 第三轮):① 澄清“自适应范围/时机”的准确说法(并修正记忆里不完善的表述); +> ② 「适应视图」与「Y 轴自适应」互相干扰(相互切换都会重绘、区间会变); +> ③ 「适应视图」在单点信号上让数据点“飞了”;④ 光标吸附没有考虑「调整 → 时间轴偏移」。 +> 离屏验证 **39/39 通过**(连跑 3 次稳定),A/B 回退三项修复后 **7 项如实失败**。 + +#### 一、自适应范围/时机的准确说法(用户澄清) + +* **首个网格**显示内容时做完整自适应:**Y 轴与时间轴都**匹配自己的数据范围; +* **只有一个数据点**的信号必须显示在网格**中间**(`FitSinglePointSignal()`:居中 + 1s + 1); + ⚠️ **工具栏「适应视图」也要走这条路**(仅一个网格 + 唯一可见信号为单点时), + 否则数据点会被挤到网格边缘、看起来“飞了”。 +* **后续新增网格**只做 **Y 轴**自适应(`ReapplyGlobalAutoFitIfEnabled`),**不再调整时间轴**。 + ⇒ 记忆里“额外要求视界内至少有一个可见数据点”这句是**不完善**的表述,完整含义见上 + (“可见”指的是单点信号要被居中摆在网格里而不是挪出视界)。 + +#### 二、「适应视图」与「Y 轴自适应」互不干扰(`PadValueRange`) + +- **根因**:两条自适应路径的**留白不同** —— 整段自适应(`ComputeLaneRange` → `PadRange`)用 + **6%**,「选区 Y 轴自适应」(`AutoRangeLaneToView`)用 **5%**。视界未变时两者本应完全相等, + 于是“点过适应视图 → 打开 Y 轴自适应”会算出不同区间、网格被无意义重绘(实测 **143513 像素**), + 反向操作同样(**137326 像素**)。 +- **修法**:新增 `DataModel.hpp` 的 **`PadValueRange()`**(含退化区间规则)作为**唯一**留白实现, + `PadRange` 与 `AutoRangeLaneToView` 都调它。 +- 验证:`[A]` 在“适应视图 → 开自适应 → 关自适应 → 再适应视图”四个步骤上断言 + **生效区间逐位相同**且**绘图区逐像素 0 差异**(含对照组:真缩放会重绘 79581 像素)。 + ⚠️ 断言必须用 `ComputeLaneRange` 取**生效区间** —— `autoRange == true` 时 + `Lane::yMin/yMax` 只是初始占位值(±1),直接读它会得到假失败。 + +#### 三、吸附跟随「调整 → 时间轴偏移」 + +- **根因**:采样点 k 画在 `time[k] + offsetTime` 处,而光标存的也是**显示时间**, + 但吸附一直在**源时间**空间查找 —— 设过时间轴偏移后,吸附会落到历史位置(用户报告)。 +- **修法**:`SignalSnapEnabled` / `HasVisibleSample` / `NearestSnapSample` / `LocalSampleSpacing` + 全部增加 `timeShift` 形参:可见性与点数统计比 `time[k] + timeShift`, + `NearestSnapSample` 内部用 `time - timeShift` 回源空间查找、返回时再加回去 + ⇒ `SnapTimeToSample` 拿到的 `candidate` 是**画面上的**时刻;`UpdateAutoScatterStyles` 同步传 `offsetTime`。 +- 验证:`[D]` 偏移 +0.3 时“靠近画面上的点”必须吸到 `源时刻 + 0.3`;偏移=0 时行为不变; + 偏移把点移出视界时不开启吸附也不吸(不会吸到看不见的位置)。 + +--- + ## 10. 变更日志(本文件维护记录) | 日期 | 设备/模型 | 摘要 | @@ -1705,6 +1750,7 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( | 2026-10-15 | DeepSeek-V4.1-Flash(**起源设备**) | **信号列表滚动越界绘制 + 边沿统计面板「显示冻结」(详见 §9.28)**。① **列表把组名/信号名画到筛选框上**(MF4 多信号场景):根因是 `UiText` 走 GDI `DrawTextW`,**GDI 文本不遵循 GDI+ 的 `SetClip`** —— 被滚出视口的「部分可见」行会把文字画到列表上方的筛选框上(`UiFillRect` 的大面积不透明矩形走 GDI `FillRect` 同理)。修法:`PaintUtil` 新增 **`UiTextClipped`**(同一 HDC `SaveDC` + `IntersectClipRect` + `RestoreDC`;`UiText` 转发 `clip=nullptr`,行为不变),`SignalPanel` 新增 `PaintRowText`(裁到 `m_listRect`)/ `FillRowRect`(夹到 `m_listRect`),分组行与信号行的全部文本与行底色均改走它们。② **边沿统计面板在不移动鼠标时显示冻结**:面板像素图层缓存键(`PanelSlotKey`)**不含**面板上显示的统计数字,而统计是**分帧推进**的中间结果 ⇒ 推进不会让键变化 ⇒ 面板一直贴旧像素 ⇒ 显示停在「统计中…」/中间值,**只有移动鼠标**(改变面板层序/悬停/鼠标坐标等其它键)才重绘并显示早已算完的正确结果(扫描推进本身没停)。修法:`PanelSlotKey` 内按面板实际显示的条目回调 `EdgeCountsFor`/`EdgeCenterCountsFor` 并把 `(rising, falling, complete)` mix 进键。验证:新增离屏 `.preflight/check`(`SW_HIDE` + 不注入真实输入 + `BELOW_NORMAL`)**39/39 通过**(连跑两次稳定):`[A]` 40 信号滚轮下滚后「列表上方区域逐像素 0 变化」且「列表内部仍有变化」(修复前 **1571 像素被污染**)、`[B]` 3 信号 × 300 万采样下两种统计面板的**缓存键持续变化**且**无鼠标输入即完成**(修复前键不变化)、`[C]` 真实 `MF4-Record.MF4`(**124 信号 / 3 条时间轴**)同样 0 像素变化;**并做了 A/B**(`git stash` 回退修复后 `[A]/[B]` 如实失败);`make release` 零警告。 | | 2026-10-15 | DeepSeek-V4.1-Flash(**起源设备**) | **全局 Y 轴自适应跟随网格结构 + 组名悬浮 + 稀疏信号默认点线 + 单点光标吸附 + 放大自动点线(详见 §9.29)**。① **新网格不继承全局 Y 轴自适应**:`RebuildLanes` 对新出现的信号返回默认 `LaneCarry()` ⇒ `fitYToSelection=false`;修法:重建前先算 `previousAllAutoFit`(非空且**每个**网格都开着 = 全局开启的正式定义),`LaneCarry` 加 `matched` 标记,新信号用它当默认;并新增 `ReapplyGlobalAutoFitIfEnabled()` 在 **`Layout()`(网格数变化)/`RefreshLanes()`(信号数变化)/`SplitLane()`** 之后立即重算 ⇒ 新勾选(按**当前视界**自适应)、取消显示(剩余信号重新收敛)、拆分(每份各自自适应)三项全覆盖。② **组名悬浮**:新增 `SignalPanel::StickyGroupRowIndex()` + `PaintStickyGroupRow()`(不透明底 + 底部边界线),`RowRect` 加 `m_rowYOverrideActive/Y` 覆盖行顶部(行内所有几何的唯一来源);判据 = 分组行已在列表上边界之上且本组最后一行仍在列表内;常规循环**跳过**被钉住的行(否则画两次)。③ **稀疏信号默认样式**:新增 `ApplySparseDefaultStyle`(1 个有效点 → 点线 + **最大线宽 10px**;2..16 → 点线 + 2px),在 `BuildSignalGroup`/`InsertDerivedSignal` 两处调用;线宽常量统一到 `DataModel.hpp`(`kSignalLineWidthMin/Max`)并被 SignalPanel 滑块引用;`ComputeStats` 输出 `firstFiniteIndex`、`SignalData` 新增 `singlePointTime`。④ **单点光标吸附**:`SnapTimeToSample` 新增半径分支(`kCursorSnapPointRadiusPixels=8` 逻辑像素)吸附 `singlePointTime`,半径外自由移动(**吸附而非卡住**)。⑤ **放大自动点线**:`SignalStyle` 新增 `autoScatterActive/autoScatterSavedMode/autoScatterUserOverride`,`AxisSparseInView`(间距 > 半个绘图区宽度,与吸附上门限同源,**单点轴不算稀疏**)+ `UpdateAutoScatterStyles()`(在 `SetTimeView` 等四处调用)⇒ 进入阈值自动切点线、退出阈值恢复原样式,**用户手动改过则不恢复**(判定必须放在恢复判断**之前**,否则用户改完紧接着缩出去会被覆盖)。验证:新增离屏 `.preflight/check` **65/65 通过**(连跑 3 次稳定,覆盖 7 组断言;含“钉住行与自然位置逐像素一致”“单点吸附半径内外”“用户手动改样式后退出不覆盖”),**A/B**:临时禁用六个机制后 **21 项如实失败**;`make release` 零警告。 | | 2026-10-15 | DeepSeek-V4.1-Flash(**起源设备**) | **组名平滑悬浮 + 单点信号初始视图 + 光标吸附统一判定(自动点线同源)(详见 §9.30)**。① **组名悬浮平滑化**:位置改为 `StickyGroupRowTop = min(列表上边界, 本组最后一行下边界 − 组名高度)` ⇒ 组名随最后一行**自然滚出**(不再突然消失),且下边界永不越过下一个分组行的自然位置 ⇒ **不再遮住下一组名**(旧实现固定钉在列表上边界会把下一组名盖住,让别人以为后面的信号属于上一组);**折叠的分组不悬浮**(随滚动自然划过);底部边界线只在真的“浮”起来时才画。② **单点信号初始视图**:新增 `PlotView::FitSinglePointSignal()`(无任何信号被勾选、勾选的第一个信号只有 1 个有效点时生效)—— 数据点居中、**时间跨度 1s**、**Y 跨度 1**,并置 `autoRange=false`/`fitYToSelection=false`;`Application::SynchronizeView` 首次勾选时先试它、失败才 `FitAll()`;`AutoRangeLaneToView` 的退化区间改为 `PadRange` 同款留白 `max(1,|v|×0.1)`。③ **吸附判定统一**(用户给出的唯一判据):新增 `GridMinorCellCount()`(网格一横排小格数 N,与网格绘制同源)+ `SignalSnapEnabled()`(**当且仅当**某信号在当前一屏内的数据点 `< N×4` 才开启吸附,随视界实时重算 ⇒ 疏密信号自适应),`SnapTimeToSample` **只按鼠标所在网格**判定、同网格内所有符合判定的信号数据点都参与、吸附半径 = 一个网格小格宽且不超过半个局部采样间距(吸附≠卡住);旧的 `SampleSpacingPixels`(6px 下门限+半屏宽上门限)**已删除**。④ **自动点线改为与吸附判定完全同源**(`UpdateAutoScatterStyles` 调 `SignalSnapEnabled`),旧 `AxisSparseInView` **已删除**;进入阈值自动切点线、不再满足自动恢复、用户手动改过则不恢复。⑤ **需求作废**:“2…16 个有效采样点默认点线 + 2px”**已取消**(只保留“1 个有效点 → 点线 + 最大线宽”);`SignalData::singlePointTime` 升级为 **`sparseTimes`/`sparseValues`**(有效点 ≤ 1024 时全部缓存,供吸附与单点视图使用)。验证:新增离屏 `.preflight/check` **55/55 通过**(连跑 3 次稳定;覆盖组名悬浮四种场景、单点初始视图、吸附判定/网格作用域/多信号/动态切换/单点半径、自动点线同源与用户覆盖);**A/B** 禁用三项机制 ⇒ **14 项如实失败**;`make release` 零警告。 | +| 2026-10-15 | DeepSeek-V4.1-Flash(**起源设备**) | **自适应范围/时机澄清 + 适应视图与 Y 轴自适应互不干扰 + 吸附跟随时间偏移(详见 §9.31)**。① **澄清“自适应范围/时机”**(修正记忆里“额外要求视界内至少有一个可见数据点”这句不完善的表述):**首个网格**显示内容时 **Y 轴与时间轴都**自适应;只有一个数据点的信号要**居中显示在网格里**(`FitSinglePointSignal`:居中 + 时间跨度 1s + Y 跨度 1);**后续新增网格只做 Y 轴自适应、不再调整时间轴**。② **「适应视图」与「Y 轴自适应」互相干扰**:根因是两条自适应路径**留白不同**(`PadRange` 6% vs `AutoRangeLaneToView` 5%)⇒ 视界未变时也会算出不同区间、网格被无意义重绘(实测切一次 **143513** 像素、反向 **137326** 像素)。修法:新增 `DataModel.hpp` 的 **`PadValueRange()`** 作为唯一留白实现(含退化区间 `max(1,|v|×0.1)`),`PadRange` 与 `AutoRangeLaneToView` 共用 ⇒ 四个步骤(适应视图→开自适应→关自适应→再适应视图)**区间逐位相同且绘图区逐像素 0 差异**。③ **「适应视图」在单点信号上让数据点“飞了”**:`FitAll()` 开头先试 `FitSinglePointSignal()`(仅一个网格 + 唯一可见信号为单点),与首次加载效果一致且幂等。④ **吸附跟随「调整 → 时间轴偏移」**:采样点画在 `time[k] + offsetTime` 处而光标存的是显示时间,旧实现按源时刻吸附 ⇒ 落到历史位置;修法:`SignalSnapEnabled`/`HasVisibleSample`/`NearestSnapSample`/`LocalSampleSpacing` 全部加 `timeShift`,查找时 `time - timeShift` 回源空间、返回时加回去,`UpdateAutoScatterStyles` 同步传 `offsetTime`。验证:离屏 `.preflight/check` **39/39 通过**(连跑 3 次稳定;含像素级零变化断言与“真缩放会重绘”对照组);**A/B** 回退三项修复 ⇒ **7 项如实失败**(含 143513/137326 像素重绘症状);`make release` 零警告。 | --- @@ -1925,24 +1971,42 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸( 58. ⚠️ **光标吸附的判定只有一套定义(§9.30)**:某信号在当前一屏内的数据点个数 `< N × 4`(N = `GridMinorCellCount()`,网格一横排的小格数,与网格绘制同源)时 才为它开启吸附;判据**随视界实时重算**(疏密变化的信号自动在稀疏区吸附、稠密区自由)。 - ⚠️ 额外要求视界内**至少有一个可见数据点**; + ⚠️ 可见性与点数一律按**显示时间**(`采样时刻 + offsetTime`)判断 + (`SignalSnapEnabled/HasVisibleSample/NearestSnapSample/LocalSampleSpacing` 都带 `timeShift`); + 视界内至少要有**一个可见数据点**(否则不开启)。 ⚠️ 判定**只针对鼠标所在网格**(`SnapTimeToSample` 用 `LaneAtY(m_mouseY)`); - ⚠️ 吸附半径 = **一个网格小格宽**,且**不超过半个局部采样间距**(吸附 ≠ 卡住光标); - ⚠️ 旧的 `SampleSpacingPixels`(6px 下门限 + 半屏宽上门限)**已删除,不要再恢复**。 + ⚠️ 吸附半径 = **一个网格小格宽**,且**不超过半个局部采样间距**(吸附 ≠ 卡住光标)。 + ⚠️ 旧的 `SampleSpacingPixels`(6px 下门限 + 半屏宽上门限)**已删除,不要再恢复**; + 且吸附必须落在**画面上**的数据点(源时刻 + `offsetTime`),不能吸到历史位置。 ⚠️「放大自动切点线」必须**与吸附判定完全同源**(`UpdateAutoScatterStyles` 调 `SignalSnapEnabled`),不要再引入“采样间距 > 半屏宽”之类的独立判据。 ⚠️ `SignalData` 用 **`sparseTimes` / `sparseValues`**(有效点 ≤ 1024 时缓存全部有效点) 支撑吸附与单点初始视图;**不要**在鼠标移动时扫描整条采样数组。 ⚠️ `LocalSampleSpacing` 对 `finiteCount <= 1` 必须返回 0(数据点间距无定义), 否则单点信号永远吸不住。 + ⚠️「放大自动切点线」必须**与吸附判定完全同源**(`UpdateAutoScatterStyles` 调 + `SignalSnapEnabled`,同样传入 `offsetTime`),不要再引入“采样间距 > 半屏宽”之类的独立判据。 + ⚠️ 判定与查找都在**显示时间**空间:`NearestSnapSample` 内部按 `timeShift` 换算回源空间、 + 返回时再加回去,因此存在「调整 → 时间轴偏移」时也能吸到画面上的数据点。 59. ⚠️ **「2…16 个有效采样点用点线 + 2px」的需求已作废**(用户 2026-10-15 明确取消)。 仍保留:**只有一个有效采样点** → 「点线」+ **最大线宽**;其余信号保持默认「线段 + 1px」。 需要“提示数据点位置”的场景一律由 §9.30 的“放大自动点线”承担(同源判定)。 -60. ⚠️ **单点信号的初始视图**:当前无任何信号被勾选、而勾选的第一个信号只有**一个**有效数据点时, - 必须走 `FitSinglePointSignal()`(数据点居中、**时间跨度 1s**、**Y 轴跨度 1**), - 不要再落到 `FitAll()` 的整段自适应(时间轴会覆盖整条时间列、Y 区间退化成几乎为 0)。 - 同时 `AutoRangeLaneToView` 的退化区间要按 `PadRange` 同款留白 `max(1, |v|×0.1)`。 -61. ⚠️ **组名悬浮的位置公式(勿改回“固定钉在列表上边界”)**: +60. ⚠️ **自适应的范围与时机(用户 2026-10-15 澄清,这是“可见”要求的准确含义)**: + * **首个网格**显示内容时,要对它做完整自适应 —— **Y 轴与时间轴都**匹配自己的数据范围; + * **只有一个数据点**的信号必须显示在网格**中间**(否则时间轴覆盖整条时间列、 + Y 区间退化成几乎为 0,数据点被挤到边缘甚至看不出位置)。 + ✓ 走 `FitSinglePointSignal()`(数据点居中、**时间跨度 1s**、**Y 轴跨度 1**); + ⚠️ **工具栏「适应视图」也要走这条路**(仅一个网格 + 唯一可见信号为单点时), + 否则用户点“适应视图”后数据点会“飞了”。 + * **后续新增网格**只做 **Y 轴**自适应,**不再调整时间轴**(`ReapplyGlobalAutoFitIfEnabled` + 挂在 `Layout`/`RefreshLanes`/`SplitLane`,这些路径都不碰 `Tab().ViewMin/ViewMax`)。 + * 退化区间(恒定值 / 单点)的留白统一为 `max(1, |值|×0.1)`。 +62. ⚠️ **幅值留白只能有一个来源**:`PadValueRange()`(`DataModel.hpp`,6%)。 + 整段自适应(`ComputeLaneRange`)与「选区 Y 轴自适应」(`AutoRangeLaneToView`) + **必须共用它** —— 两处各自硬编码(6% / 5%)时,「点过适应视图后再打开 Y 轴自适应」 + 会算出不同区间 ⇒ 网格被无意义地重绘一次(实测 ~14 万像素)。 + 反向(关掉自适应再点适应视图)同样会变。统一后这两种操作在视界未变时**像素级零变化**。 +63. ⚠️ **组名悬浮的位置公式(勿改回“固定钉在列表上边界”)**: `min(列表上边界, 本组最后一行下边界 − 组名高度)` ⇒ 组名随最后一行**平滑滚出**(不突然消失),且下边界永不越过下一个分组行的自然位置 ⇒ **不可能遮住下一个组名**;**折叠的分组不悬浮**(随滚动自然划过)。 diff --git a/Utilities/Inc/DataModel.hpp b/Utilities/Inc/DataModel.hpp index 9254ec9..c4ba2c6 100644 --- a/Utilities/Inc/DataModel.hpp +++ b/Utilities/Inc/DataModel.hpp @@ -55,6 +55,50 @@ namespace jdi */ constexpr std::size_t kSparseTimesLimit = 1024; + /** @brief 网格 Y 轴自适应时为幅值区间补充的留白比例(相对区间跨度)。 */ + constexpr double kLaneRangePadRatio = 0.06; + + /** + * @brief 为幅值区间补充留白 —— **网格 Y 轴自适应的唯一留白规则**。 + * + * ⚠️ 必须只有这一份实现:整段自适应(`TabConfig::ComputeLaneRange`)与 + * 「选区 Y 轴自适应」(`PlotView::AutoRangeLaneToView`)都要调用它。 + * 过去两处各自硬编码(6% / 5%),于是「点过适应视图后再打开 Y 轴自适应」会因为 + * 留白不同而算出**不一样的区间** → 网格被无意义地重绘一次;反向(关掉自适应再点适应视图) + * 同样会变。统一留白后这两种操作在视界未变时都是**像素级无变化**。 + * + * 区间退化(恒定值 / 单点信号)时给一个可读的跨度 `max(1, |值|×0.1)`, + * 而不是“几乎为 0”的区间(那会让幅值网格线挤成一团、数据点也看不出位置)。 + * + * @param [IN,OUT] low 区间下限。 @param [IN,OUT] high 区间上限。 + */ + inline void PadValueRange(double& low, double& high) + { + if (!std::isfinite(low) || !std::isfinite(high)) + { + low = 0.0; + high = 1.0; + return; + } + if (low > high) + { + std::swap(low, high); + } + + const double span = high - low; + if (span <= 0.0) + { + const double magnitude = std::max(1.0, std::fabs(low) * 0.1); + low -= magnitude; + high += magnitude; + return; + } + + const double pad = span * kLaneRangePadRatio; + low -= pad; + high += pad; + } + /** * @brief 绘图区拖拽缩放模式(由工具栏按钮切换)。 */ diff --git a/Utilities/Src/DataModel.cpp b/Utilities/Src/DataModel.cpp index 35cbb71..92f229d 100644 --- a/Utilities/Src/DataModel.cpp +++ b/Utilities/Src/DataModel.cpp @@ -26,30 +26,16 @@ namespace jdi /** * @brief 为网格范围补充留白,避免波形紧贴边界。 + * + * ⚠️ 实际规则实现在 `DataModel.hpp` 的 `PadValueRange()`:它同时被 + * 「选区 Y 轴自适应」使用,两处必须逐位一致(否则两种自适应会算出不同区间)。 + * * @param [IN, OUT] yMin 范围下限。 * @param [IN, OUT] yMax 范围上限。 */ void PadRange(double& yMin, double& yMax) { - if (!std::isfinite(yMin) || !std::isfinite(yMax)) - { - yMin = 0.0; - yMax = 1.0; - return; - } - - const double span = yMax - yMin; - if (span <= 0.0) - { - const double magnitude = std::max(1.0, std::fabs(yMin) * 0.1); - yMin -= magnitude; - yMax += magnitude; - return; - } - - const double pad = span * 0.06; - yMin -= pad; - yMax += pad; + PadValueRange(yMin, yMax); } /**