组名平滑悬浮 + 单点信号初始视图 + 光标吸附统一判定(自动点线同源)

一、信号列表「组名悬浮」平滑化、且不再遮住下一个组名
 * 悬浮位置改为 min(列表上边界, 本组最后一行下边界 − 组名高度):
   - 本组还有行在列表里时钉在列表上边界;
   - 本组最后一行开始滑出上边界时,组名跟着一起上移、随滚动条自然离界
     (不再像旧实现那样突然消失);
   - 「本组最后一行的下边界」恰好就是下一个分组行的自然位置,因此悬浮组名的下边界
     永远不超过下一组名 —— 旧实现固定钉在列表上边界会把下一组名盖住,让别人以为
     后面的信号属于上一组。
 * 折叠的分组不再悬浮(折叠时本组“最后一行”就是它自己,悬浮位置 = 自然位置,
   于是随滚动自然划过,不会停在最上方)。
 * 底部边界线只在真的“浮”在其它行之上时才画,避免与正常绘制出现像素差。

二、单点信号的初始视图
 * 新增 PlotView::FitSinglePointSignal():当前没有任何信号被勾选、而勾选的第一个信号
   只有一个有效数据点时,把该数据点居中放置,时间跨度 = 1s、Y 轴跨度 = 1
   (各为一个大格),并关闭该网格的整段自适应以免被覆盖。
   Application::SynchronizeView 在首次勾选时先尝试它,失败才走 FitAll()。
 * AutoRangeLaneToView 的退化区间(可见区间内只有一个取值)改用与 PadRange 一致的留白
   max(1, |v|×0.1),不再产生“几乎为 0”的区间(那会让幅值网格线挤成一团、看不出数据点)。
 * 需求作废:上一轮“2…16 个有效采样点默认改为点线 + 2px”已取消(放大自动点线已覆盖该场景);
   只保留“只有一个有效采样点 → 点线 + 最大线宽”。
 * SignalData::singlePointTime 升级为 sparseTimes/sparseValues(有效点 ≤ 1024 时在加载/插入
   时缓存全部有效点),同时服务单点初始视图与光标吸附。

三、光标吸附的统一判定
 * N = GridMinorCellCount():网格底衬一横排的小格数,与网格绘制用同一套
   NiceStep + 五等分算出(因此不随缩放等级大幅变化)。
 * SignalSnapEnabled():当且仅当某信号在当前一屏内的数据点个数 < N×4 时为它开启吸附;
   判据随视界实时重算 ⇒ 疏密变化的信号在稀疏区自动吸附、移回稠密区自动自由移动。
   额外要求视界内至少有一个可见数据点(否则“0 < N×4”会把数据全在视界之外的信号
   误判为稀疏)。统计只在可见下标区间内进行且攒够阈值即早退,代价有界。
 * SnapTimeToSample():判定只针对鼠标所在网格(同一个时间轴上不同网格结果可以不同);
   同一网格内所有符合判定的信号的数据点都参与吸附比较;
   吸附半径 = 一个网格小格的宽度,且不超过半个局部采样间距 ⇒ 数据点很稀疏
   (一屏只有一两个点)时光标在点之间仍可自由移动(吸附是提示,不是卡住)。
 * 删除旧的 SampleSpacingPixels(6px 下门限 + 半屏宽上门限)。

四、放大自动切点线改为与吸附判定完全同源
 * UpdateAutoScatterStyles() 改用 SignalSnapEnabled:进入判定自动切「点线」,
   不再满足(适应视图、或视界从稀疏区移到稠密区)自动恢复原样式;
   自动期间用户手动改过则不恢复(尊重用户选择,判定放在恢复之前)。
 * 删除旧的 AxisSparseInView(“采样间距 > 半屏宽”)。

五、验证(离屏 .preflight/check:SW_HIDE 窗口、不注入真实鼠标键盘、进程/线程 BELOW_NORMAL)
 55/55 通过(连跑 3 次稳定):[A] 组名悬浮(钉住/平滑上移/整组滚过后换下一组/折叠组不悬浮/
 悬浮下边界不超过下一组名)、[B] 单点初始视图(跨度 1s 与 1、居中、点线 + 最大线宽、
 两信号时不再走单点路径)、[C] 吸附判定(满视图都关闭/稀疏窗口开启/无可见点不开启/
 同一时间轴不同网格结果不同/同网格多信号的点都参与/稠密↔稀疏双向动态切换/
 单点靠近吸附远离自由)、[D] 自动点线(同源判定/恢复/用户手动改过不覆盖)。
 A/B:临时禁用「悬浮位置公式 / 单点初始视图 / 吸附判定」后重跑 ⇒ 14 项如实失败。
 make release 零警告(用户正运行 Target.exe,链接用临时 -o 名字验证通过)。

六、规则同步
 Documents/agent_mem.md 新增 §9.30、§11.2 第 58–61 条硬约束与变更日志。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-10-15 15:10:36 +08:00
1 parent 9f6de3bba4
commit 57cc714c1d
8 files changed
+808 -230

No files matched your search

+6 -1
View File
@@ -627,7 +627,12 @@ namespace jdi
}
else if (!tab.ViewInitialized())
{
m_plot.FitAll();
// 「单点信号」有专门的初始视图(数据点居中、时间跨度 1s、幅值跨度 1):
// 整段自适应对它不适用(时间轴会覆盖整条时间列、幅值区间退化成几乎为 0)。
if (!m_plot.FitSinglePointSignal())
{
m_plot.FitAll();
}
}
const bool laneCountChanged = (laneCount != previousLaneCount);
+83 -26
View File
@@ -310,11 +310,12 @@ namespace jdi
void ReapplyGlobalAutoFitIfEnabled();
/**
* @brief 依据「采样点是否稀疏」自动启用/恢复「点线」绘制方式。
* @brief 依据「光标吸附判定」自动启用/恢复「点线」绘制方式。
*
* 用户要求(2026-10-15):把波形放大到一定程度(此时光标会吸附数据点)后,
* 线段模式已经看不出数据点在哪 → 自动为该信号启用「点线」提示;视界回退到阈值
* 之外(例如适应视图)时自动恢复**原来的**绘制方式。
* 用户要求(2026-10-15):**仅在满足光标吸附判定的信号上**生效 ——
* 即“一屏内数据点少于 N×4”(见 `SignalSnapEnabled`)时自动切到「点线」提示
* 用户数据点的位置;一旦不再满足(适应视图、或把视界从稀疏区移到稠密区)
* 自动恢复**原来的**绘制方式。
*
* ⚠️ 自动启用期间若用户**手动**改了绘制方式(`SignalStyle::autoScatterUserOverride`
* 由本函数自动识别:自动状态下 mode 不等于 `LINE_SCATTER` 即说明是用户改的),
@@ -325,24 +326,76 @@ namespace jdi
bool UpdateAutoScatterStyles();
/**
* @brief 判断某个时间轴在当前视界下是否已经“稀疏到需要点线提示”。
* @param [IN] axis 目标时间轴。
* @return 采样点间距超过半个绘图区宽度返回 true。
* @brief 网格底衬**一横排的小格数量 N**(与 `PaintLane` 画出来的网格同源)。
*
* 时间刻度用 `NiceStep(span, kTimeTickTarget)` 定主刻度、再五等分 ⇒
* 不管缩放等级如何,一横排总是差不多同样多的小格。
*
* @return 小格数量(无法判定时返回 0)。
*/
bool AxisSparseInView(const TimeAxis& axis) const;
int GridMinorCellCount() const;
/**
* @brief 判断某信号在当前视界下是否**开启光标吸附**。
*
* 用户 2026-10-15 的定义:**当且仅当**该信号在“当前一屏”(当前视界)内的数据点
* 个数 `< N × 4`(N = `GridMinorCellCount()`)时开启吸附。
* 判据随视界实时重算 ⇒ 疏密变化的信号在稀疏区自动吸附、稠密区自动自由移动。
*
* ⚠️ 额外要求视界内**至少有一个可见数据点**:数据点全在视界之外的信号既没有可吸附
* 的对象、也没有可提示的内容(否则它会被判成“0 < N×4 稀疏”而白切一次「点线」)。
*
* @param [IN] axis 该信号使用的时间轴。
* @param [IN] signal 目标信号。
* @return 开启吸附返回 true。
*/
bool SignalSnapEnabled(const TimeAxis& axis, const SignalData& signal) const;
/**
* @brief 判断某信号在给定可见下标区间内是否存在**落在视界内**的有效采样点。
* @param [IN] axis 该信号使用的时间轴。
* @param [IN] signal 目标信号。
* @param [IN] begin 可见下标区间起点。 @param [IN] end 终点(不含)。
* @return 存在返回 true。
*/
bool HasVisibleSample(const TimeAxis& axis, const SignalData& signal, std::size_t begin,
std::size_t end) const;
/**
* @brief 找某信号**可见区间内**离给定时刻最近的有效采样点。
*
* 稀疏信号(有效点已缓存)用二分;一般信号从最近下标向两侧**有界**探测
*(NaN 只出现在缺少数据的区段)。视界之外的采样点不是合法落点,一律排除。
*
* @param [IN] axis 该信号使用的时间轴。
* @param [IN] signal 目标信号。
* @param [IN] time 目标时刻。
* @param [OUT] candidate 最近的可见有效采样点时刻。
* @return 找到返回 true。
*/
bool NearestSnapSample(const TimeAxis& axis, const SignalData& signal, double time,
double& candidate) const;
/**
* @brief 取某时刻附近的**局部采样间距**(时间单位,用于限制吸附半径)。
* @param [IN] axis 该信号使用的时间轴。
* @param [IN] signal 目标信号。
* @param [IN] time 目标时刻。
* @param [OUT] spacing 相邻采样点的时间间隔(无法判定时为 0)。
*/
void LocalSampleSpacing(const TimeAxis& axis, const SignalData& signal, double time,
double& spacing) const;
/**
* @brief 把时间吸附到距今最近的数据采样点。
*
* 仅在横向放大到采样点间距足够大(可精确分辨)时生效,
* 避免缩小时光标出现“抖动”难以微调。
*
* ⚠️ 吸附有**上下两个门限**:
* * 下门限:采样点间距 ≥ `kCursorSnapMinPixels`(缩小时不吸附,否则难以微调);
* * 上门限:视界内至少能容纳 **2 个**采样点(`pixels <= plotWidth / 2`)。
* 视界比采样间隔还窄时“最近采样点”在视界之外,吸附会把光标一把甩到视界边缘
* 并钉住(表现为“一拖光标就飞到视界边缘、跟不动鼠标”)。
* * 并且只接受**落在当前可见区间内**的采样点。
* 判定与半径(用户 2026-10-15 统一后的定义):
* * **判定只针对鼠标所在的网格**:该网格内的信号各自按 `SignalSnapEnabled` 判定,
* 符合条件的信号的数据点全部参与吸附;网格内没有符合条件的信号时完全自由移动;
* * 同一网格内有多条信号时,所有符合条件的数据点都参与比较;
* * 吸附半径 = **一个网格小格的宽度**(与判定同源),且**不超过半个局部采样间距**
* ⇒ 数据点很稀疏(一屏只有一两个点)时光标在点与点之间仍可自由移动,
* 而不是被“死死吸住”(吸附只是提示性响应,不是卡住光标)。
*
* @param [IN] time 原始时间。
* @param [OUT] snapped 吸附后的时间。
@@ -350,15 +403,6 @@ namespace jdi
*/
bool SnapTimeToSample(double time, double& snapped) const;
/**
* @brief 计算指定文件中某一时刻附近采样点的时间间隔。
* @param [IN] file 目标文件。
* @param [IN] time 目标时刻。
* @param [OUT] pixels 间隔换算到屏幕上的像素宽度。
* @return 计算成功返回 true。
*/
bool SampleSpacingPixels(const TimeAxis& axis, double time, double& pixels) const;
/**
* @brief 判断坐标是否落在绘图区范围内。
* @param [IN] x 客户区横坐标。
@@ -380,6 +424,19 @@ namespace jdi
*/
void FitAll();
/**
* @brief 「单点信号」的初始视图:把唯一的数据点居中,时间跨度 1s、幅值跨度 1。
*
* 用户要求(2026-10-15):当前**没有任何信号被勾选**、而勾选的第一个信号
* **只有一个有效数据点**时,整段自适应并不能把它的状态显示出来
*(时间轴会覆盖整条时间列、幅值区间退化成几乎为 0)。
* 因此这种情况改为:数据点居中放置,**时间轴跨度 = 一个大格 = 1s**、
* **Y 轴跨度 = 一个大格 = 1**。
*
* @return 已按“单点信号”初始化返回 true(调用方无需再做 `FitAll`)。
*/
bool FitSinglePointSignal();
/**
* @brief 以给定倍率缩放时间轴(以视图中心为锚点)。
* @param [IN] factor 缩放倍率,大于 1 表示放大。
+441 -119
View File
@@ -122,14 +122,40 @@ namespace jdi
g.FillPolygon(&brush, arrow, 3);
}
const float kCursorSnapMinPixels = 6.0f; /**< 光标吸附采样点的最小采样间隔(逻辑像素)。 */
/**
* @brief 「只有一个数据点」的信号的光标吸附半径(逻辑像素)。
* @brief 光标吸附的**判定系数**:一屏内的数据点少于 `网格小格数 × 该系数` 时才开启吸附。
*
* 这类信号没有“采样间距”可依据,因此改用**半径判据**:光标距该点不超过该半径时吸附过去,
* 其它区域自由移动(用户明确要求“吸附而不是卡住光标”,只是一个提示性响应)。
* 用户 2026-10-15 明确定义:网格底衬一横排有 N 个小格(见 `GridMinorCellCount`),
* **当且仅当**某信号在当前视界内的数据点个数 `< N × 4` 时为它开启光标吸附。
* 该判据随视界实时重算 ⇒ 疏密变化的信号在稀疏区自动吸附、稠密区自动自由移动。
*/
const float kCursorSnapPointRadiusPixels = 8.0f;
const int kSnapVisibleCellsPerPoint = 4;
/**
* @brief 吸附半径(以**网格小格**为单位):一个数据点只用其**一个小格宽**内的移动来吸。
*
* 吸附半径 = 一个网格小格的宽度(与判定用同一套网格定义),且**不超过半个采样间距**
* ——后者保证点很稀疏时(例如一屏只有一两个点)光标在点与点之间仍可自由移动,
* 而不是被“死死吸住”(用户明确要求:吸附只是提示性响应)。
*/
const double kSnapRadiusCells = 1.0;
/**
* @brief 一般信号查找“离光标最近的有效采样点”时向两侧探测的最大步数。
*
* 采样值里的 NaN 只出现在缺少数据的区段(例如多时间轴 CSV 的较短轴的尾部),
* 因此从最近下标向外少量探测即可找到有效点;探测失败就不吸附(宁可自由移动)。
*/
const int kSnapSampleProbeSteps = 64;
/**
* @brief 判定“可见数据点是否足够少”时允许扫描的最大样本数(相对阈值的倍数)。
*
* 判定只需知道“有效点是否少于阈值”,因此扫到阈值个有效点就早退;
* 但连续 NaN 不会被计入,因此再给一个**有界**的扫描窗口防止极端情况下的长循环。
*/
const std::size_t kSnapScanWindowFactor = 64;
const float kLaneLabelPad = 6.0f; /**< 网格内侧标尺带的内边距。 */
const float kLegendHeight = 19.0f; /**< 图例条目高度(逻辑像素)。 */
const float kLegendPad = 5.0f; /**< 图例相对网格左上角的内缩。 */
@@ -1346,6 +1372,104 @@ namespace jdi
BuildLaneVisuals();
}
bool PlotView::FitSinglePointSignal()
{
if (m_tab == nullptr || m_dataSet == nullptr || m_dataSet->Empty())
{
return false;
}
// 只在「当前没有任何信号被勾选,勾选的第一个信号只有一个有效数据点」时生效
// (用户 2026-10-15 明确要求):此时整段自适应会让时间轴覆盖整条时间列、
// Y 轴退化成几乎为 0 的区间,数据点既不在中心也看不出来。
SignalRef only;
int visibleCount = 0;
for (const Lane& lane : m_tab->Lanes())
{
for (const SignalRef& ref : lane.signals)
{
if (!m_tab->IsVisible(ref))
{
continue;
}
only = ref;
if (++visibleCount > 1)
{
return false; // 多个可见信号:按常规的整段自适应处理
}
}
}
if (visibleCount != 1)
{
return false;
}
const SignalData* signal = m_dataSet->Resolve(only);
const TimeAxis* axis = m_dataSet->Axis(only);
if (signal == nullptr || axis == nullptr || signal->finiteCount != 1)
{
return false;
}
// 取出那个唯一数据点的时刻与取值(加载时已缓存为稀疏点)。
double pointTime = std::numeric_limits<double>::quiet_NaN();
double pointValue = std::numeric_limits<double>::quiet_NaN();
if (!signal->sparseTimes.empty() && signal->sparseTimes.size() == signal->sparseValues.size())
{
pointTime = signal->sparseTimes.front();
pointValue = signal->sparseValues.front();
}
else
{
// 兜底:逐个采样找那个有效点(单点信号的采样数组必然很短)。
const std::size_t count = std::min(axis->time.size(), signal->values.size());
for (std::size_t index = 0; index < count; ++index)
{
if (std::isfinite(signal->values[index]) && std::isfinite(axis->time[index]))
{
pointTime = axis->time[index];
pointValue = signal->values[index];
break;
}
}
}
if (!std::isfinite(pointTime) || !std::isfinite(pointValue))
{
return false;
}
// 时间轴:跨度 = **一个大格(1s)**,数据点居中。
const double halfTimeSpan = 0.5;
SetTimeView(pointTime - halfTimeSpan, pointTime + halfTimeSpan);
Tab().ViewInitialized() = true;
Tab().LaneScroll() = 0.0f;
Tab().ZoomMode() = PlotZoomMode::NONE;
m_rubberRect = gd::RectF();
m_dragMode = PlotDragMode::NONE;
m_rubberAxis = PlotRubberAxis::UNDECIDED;
SetHint(std::wstring());
// Y 轴:跨度 = **一个大格(1)**,数据点居中。
// ⚠️ 必须同时关掉 `autoRange`(否则会被整段自适应覆盖)与 `fitYToSelection`
//(选区自适应会按可见区间算出退化区间;全局 Y 轴自适应开启时那条路径也会生效)。
const double halfValueSpan = 0.5;
for (Lane& lane : m_tab->Lanes())
{
if (lane.signals.size() != 1 || !(lane.signals.front() == only))
{
continue;
}
lane.autoRange = false;
lane.fitYToSelection = false;
lane.yMin = pointValue - halfValueSpan;
lane.yMax = pointValue + halfValueSpan;
}
InvalidateSceneCache();
BuildLaneVisuals();
return true;
}
void PlotView::DisableCursor()
{
Tab().CursorMode() = PlotCursorMode::NONE;
@@ -1684,134 +1808,342 @@ namespace jdi
return m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].signals.size();
}
bool PlotView::SampleSpacingPixels(const TimeAxis& axis, double time, double& pixels) const
{ pixels = 0.0;
int PlotView::GridMinorCellCount() const
{
// 与实际画出来的网格用同一套算法(`PaintLane`/`PaintTimeAxis` 的 `NiceStep` + 五等分),
// 因此这里得到的“一横排小格数”就是用户看到的格子数。
if (m_plotRect.Width <= 1.0f)
{
return 0;
}
const double span = Tab().ViewMax() - Tab().ViewMin();
if (!(span > 0.0) || !std::isfinite(span))
{
return 0;
}
const double majorStep = NiceStep(span, kTimeTickTarget);
const double minorStep = majorStep / static_cast<double>(kMinorDivisions);
if (!(minorStep > 0.0) || !std::isfinite(minorStep))
{
return 0;
}
const double cells = span / minorStep;
if (!std::isfinite(cells) || cells <= 0.0)
{
return 0;
}
// 视界两端各可能只覆盖半格,四舍五入到最近的整格数即可(判据只用到它 × 4)。
return std::max(1, static_cast<int>(std::llround(cells)));
}
const std::vector<double>& samples = axis.time;
if (samples.size() < 2 || m_plotRect.Width <= 1.0f)
bool PlotView::SignalSnapEnabled(const TimeAxis& axis, const SignalData& signal) const
{
const int cells = GridMinorCellCount();
if (cells <= 0 || signal.finiteCount == 0)
{
return false;
}
const std::size_t threshold =
static_cast<std::size_t>(cells) * static_cast<std::size_t>(kSnapVisibleCellsPerPoint);
if (threshold == 0)
{
return false;
}
const double viewSpan = Tab().ViewMax() - Tab().ViewMin();
if (!(viewSpan > 0.0))
const std::size_t count = std::min(axis.time.size(), signal.values.size());
if (count == 0)
{
return false;
}
std::size_t begin = 0;
std::size_t end = 0;
VisibleIndexRange(axis, count, begin, end);
if (end <= begin)
{
return false;
}
// ⚠️ 视界内**至少要有一个可见数据点**:一个数据点都不在屏幕上的信号既没有可吸附的
// 对象、也没有可提示的内容。少了这一条,“数据点全都落在视界之外”的信号会被判成
// “稀疏”(0 < N×4)而被自动切成「点线」,属于无意义的状态抖动。
if (signal.finiteCount < threshold)
{
// 有效点总数本来就不足阈值 ⇒ 只需确认视界内确实有点(稀疏点已缓存时二分判定)。
return HasVisibleSample(axis, signal, begin, end);
}
// 一般信号:统计**当前视界内**的有效点数量(攒够阈值立刻早退,代价有界)。
// 连续 NaN 不计入有效点,因此再给一个**有界**的扫描窗口,防止极端情况下长循环。
const std::size_t window =
std::min(std::min(end, signal.values.size()), begin + threshold * kSnapScanWindowFactor + 8u);
std::size_t seen = 0;
for (std::size_t index = begin; index < window; ++index)
{
if (!std::isfinite(signal.values[index]))
{
continue;
}
if (!std::isfinite(axis.time[index]) || axis.time[index] < Tab().ViewMin() ||
axis.time[index] > Tab().ViewMax())
{
continue;
}
if (++seen >= threshold)
{
return false; // 一屏内的数据点已经足够密:保持自由移动
}
}
return seen > 0;
}
bool PlotView::HasVisibleSample(const TimeAxis& axis, const SignalData& signal, std::size_t begin,
std::size_t end) const
{
const double viewMin = Tab().ViewMin();
const double viewMax = Tab().ViewMax();
// 稀疏信号(有效点已缓存且升序):二分即可。
if (!signal.sparseTimes.empty())
{
const std::vector<double>& times = signal.sparseTimes;
const std::vector<double>::const_iterator it = std::lower_bound(times.begin(), times.end(), viewMin);
return it != times.end() && *it <= viewMax;
}
end = std::min(end, std::min(axis.time.size(), signal.values.size()));
for (std::size_t index = begin; index < end; ++index)
{
const double time = axis.time[index];
if (!std::isfinite(signal.values[index]) || !std::isfinite(time) || time < viewMin ||
time > viewMax)
{
continue;
}
return true;
}
return false;
}
bool PlotView::NearestSnapSample(const TimeAxis& axis, const SignalData& signal, double time,
double& candidate) const
{
const double viewMin = Tab().ViewMin();
const double viewMax = Tab().ViewMax();
// 稀疏信号:有效点已在加载时缓存(按时间升序)⇒ 二分取最近的一个。
if (!signal.sparseTimes.empty())
{
const std::vector<double>& times = signal.sparseTimes;
const std::vector<double>::const_iterator it = std::lower_bound(times.begin(), times.end(), time);
bool found = false;
double bestDistance = std::numeric_limits<double>::infinity();
for (int side = 0; side < 2; ++side)
{
// side 0 = 第一个 >= time 的点;side 1 = 它前面的那个点。
if (side == 1 && it == times.begin())
{
continue;
}
const std::vector<double>::const_iterator pick = (side == 0) ? it : (it - 1);
if (pick == times.end())
{
continue;
}
const double value = *pick;
if (!std::isfinite(value) || value < viewMin || value > viewMax)
{
continue;
}
const double distance = std::fabs(value - time);
if (distance < bestDistance)
{
bestDistance = distance;
candidate = value;
found = true;
}
}
return found;
}
// 一般信号:取下标最近的采样点;它若无效(NaN)则向两侧**有界**探测
//(NaN 只出现在缺少数据的区段,通常几步内就能找到有效点;找不到就不吸附)。
const std::size_t count = std::min(axis.time.size(), signal.values.size());
if (count == 0)
{
return false;
}
const int center = FindNearestSample(axis, time);
if (center < 0)
{
return false;
}
const int lastIndex = static_cast<int>(count) - 1;
for (int step = 0; step <= kSnapSampleProbeSteps; ++step)
{
for (int direction = 0; direction < 2; ++direction)
{
if (step == 0 && direction == 1)
{
continue; // 中心点只查一次
}
const int probe = (direction == 0) ? (center - step) : (center + step);
if (probe < 0 || probe > lastIndex)
{
continue;
}
const double value = signal.values[static_cast<std::size_t>(probe)];
const double sampleTime = axis.time[static_cast<std::size_t>(probe)];
if (!std::isfinite(value) || !std::isfinite(sampleTime))
{
continue;
}
// 只接受**可见区间内**的采样点:视界之外的采样点不是合法的落点
//(否则吸附会把光标一把甩到视界边缘并钉住)。
if (sampleTime < viewMin || sampleTime > viewMax)
{
continue;
}
candidate = sampleTime;
return true;
}
}
return false;
}
void PlotView::LocalSampleSpacing(const TimeAxis& axis, const SignalData& signal, double time,
double& spacing) const
{
spacing = 0.0;
if (signal.sparseTimes.size() >= 2)
{
const std::vector<double>& times = signal.sparseTimes;
const std::size_t found =
static_cast<std::size_t>(std::lower_bound(times.begin(), times.end(), time) - 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)
{
const double gap = times[right] - times[left];
if (gap > 0.0 && std::isfinite(gap))
{
spacing = gap;
}
}
return;
}
// ⚠️ 有效点只有一个(或没有)时,**数据点之间的间距没有定义**:此时不能拿时间轴的
// 采样间隔充数(NaN 填充的轴上那可能是 1ms,而数据点其实孤零零一个)——
// 否则吸附半径会被压到极小、单点信号就永远吸不住。返回 0 = 不限制半径。
if (signal.finiteCount <= 1)
{
return;
}
const std::size_t count = std::min(axis.time.size(), signal.values.size());
if (count < 2)
{
return;
}
const int index = FindNearestSample(axis, time);
if (index < 0)
{
return false;
return;
}
double spacing = 0.0;
if (index > 0)
const int first = (index > 0) ? (index - 1) : 0;
const int second = std::min(static_cast<int>(count) - 1, first + 1);
if (second <= first)
{
spacing = samples[static_cast<std::size_t>(index)] - samples[static_cast<std::size_t>(index) - 1];
return;
}
else
// 只接受两端都是**有效**采样点的区间(NaN 段的“间距”没有意义)。
if (!std::isfinite(signal.values[static_cast<std::size_t>(first)]) ||
!std::isfinite(signal.values[static_cast<std::size_t>(second)]))
{
spacing = samples[1] - samples[0];
return;
}
// 时间轴非单调时前后差值可能为负,此时不做吸附。
if (!(spacing > 0.0) || !std::isfinite(spacing))
const double gap = axis.time[static_cast<std::size_t>(second)] -
axis.time[static_cast<std::size_t>(first)];
if (gap > 0.0 && std::isfinite(gap))
{
return false;
spacing = gap;
}
pixels = spacing / viewSpan * static_cast<double>(m_plotRect.Width);
return true;
}
bool PlotView::SnapTimeToSample(double time, double& snapped) const
{
if (m_dataSet == nullptr)
if (m_dataSet == nullptr || m_tab == nullptr)
{
return false;
}
const double minPixels = static_cast<double>(kCursorSnapMinPixels) * static_cast<double>(m_scale);
// 吸附的上门限:视界内至少要放得下 2 个采样点。
// 视界比采样间隔还窄时(选区放大很容易做到),"最近采样点"落在视界之外或半个视界之外,
// 吸附会把光标一把甩到视界边缘并钉住 —— 用户报告的"拖光标直接飞到视界外"。
const double maxPixels = 0.5 * static_cast<double>(m_plotRect.Width);
const double viewMin = Tab().ViewMin();
const double viewMax = Tab().ViewMax();
// 吸附判定**只针对鼠标所在的网格**(用户明确要求):
// 一个网格内的数据已达“稀疏”标准、另一个没有时,光标只在前者上吸附,
// 在后者上仍然完全自由移动。
const int laneIndex = LaneAtY(m_mouseY);
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return false;
}
const int cells = GridMinorCellCount();
const double plotWidth = static_cast<double>(m_plotRect.Width);
const double viewSpan = Tab().ViewMax() - Tab().ViewMin();
if (cells <= 0 || plotWidth <= 1.0 || !(viewSpan > 0.0))
{
return false;
}
// 吸附半径 = **一个网格小格的宽度**(与判定用同一套网格定义推出),
// 且**不超过半个采样间距** —— 后者保证数据点很稀疏时(一屏只有一两个点)
// 光标在点与点之间仍可自由移动,而不是被“死死吸住”
//(用户明确要求:吸附只是提示性响应)。
const double radiusPixels = kSnapRadiusCells * plotWidth / static_cast<double>(cells);
const double radiusTime = radiusPixels / plotWidth * viewSpan;
bool found = false;
double bestDistance = std::numeric_limits<double>::infinity();
double bestTime = 0.0;
// ---- 只有一个有效采样点的信号:**靠近才吸附**(半径判据)----
// 这类信号根本算不出“采样间距”(间距无定义),旧逻辑会直接跳过 ⇒ 光标永远无法
// 准确指到那个点(用户报告“无法准确指向数据点”)。
// 现在改为:光标在半径内才吸附过去,其它区域完全自由移动(**吸附而非卡住**,
// 只是为用户移动光标时给出提示性响应)。
const double viewSpan = viewMax - viewMin;
const double pointSnapRadiusTime =
(m_plotRect.Width > 1.0f)
? (static_cast<double>(kCursorSnapPointRadiusPixels) * static_cast<double>(m_scale) /
static_cast<double>(m_plotRect.Width) * viewSpan)
: 0.0;
if (pointSnapRadiusTime > 0.0)
// 同一个网格内可能存在多条信号:**所有**符合吸附判定的数据点都参与吸附。
for (const SignalRef& ref : m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].signals)
{
for (const SignalGroup& group : m_dataSet->Groups())
if (!m_tab->IsVisible(ref))
{
for (const SignalData& signal : group.signals)
{
const double only = signal.singlePointTime;
if (!std::isfinite(only) || only < viewMin || only > viewMax)
{
continue;
}
const double distance = std::fabs(only - time);
if (distance <= pointSnapRadiusTime && distance < bestDistance)
{
bestDistance = distance;
bestTime = only;
found = true;
}
}
continue;
}
}
for (const SignalGroup& group : m_dataSet->Groups())
{
// 只有采样点在屏幕上足够稀疏时才吸附,否则缩小视图后光标会“跳格”。
// 一个分组可能有多条时间轴(多 `time` 列 CSV / MF4 多通道组),逐条尝试。
for (const TimeAxis& groupAxis : group.axes)
const SignalData* signal = m_dataSet->Resolve(ref);
const TimeAxis* axis = m_dataSet->Axis(ref);
if (signal == nullptr || axis == nullptr)
{
double pixels = 0.0;
if (!SampleSpacingPixels(groupAxis, time, pixels) || pixels < minPixels ||
pixels > maxPixels)
{
continue;
}
continue;
}
// 只有“一屏内数据点少于 N×4”的信号参与吸附;判据随视界实时重算,
// 因此疏密变化的信号在稀疏区自动吸附、移到稠密区自动恢复自由移动。
if (!SignalSnapEnabled(*axis, *signal))
{
continue;
}
double candidate = 0.0;
if (!NearestSnapSample(*axis, *signal, time, candidate))
{
continue;
}
const int index = FindNearestSample(groupAxis, time);
if (index < 0)
{
continue;
}
const double candidate = groupAxis.time[static_cast<std::size_t>(index)];
if (!std::isfinite(candidate))
{
continue;
}
// 只吸附**当前可见区间内**的采样点:视界之外的采样点不是合法的落点。
if (candidate < viewMin || candidate > viewMax)
{
continue;
}
// 吸附半径再受“半个局部采样间距”限制(点很稀疏时不被吸死)。
double limitTime = radiusTime;
double spacingTime = 0.0;
LocalSampleSpacing(*axis, *signal, candidate, spacingTime);
if (spacingTime > 0.0)
{
limitTime = std::min(limitTime, spacingTime * 0.5);
}
const double distance = std::fabs(candidate - time);
if (distance < bestDistance)
{
bestDistance = distance;
bestTime = candidate;
found = true;
}
const double distance = std::fabs(candidate - time);
if (distance <= limitTime && distance < bestDistance)
{
bestDistance = distance;
bestTime = candidate;
found = true;
}
}
@@ -4015,9 +4347,13 @@ namespace jdi
}
else
{
const double pad = std::max(1.0e-9, std::fabs(low) * 0.05);
low -= pad;
high += pad;
// ⚠️ 可见区间内只有一个取值(单点信号 / 恒定值):给一个**可读**的跨度,
// 而不是几乎为 0 的退化区间 —— 退化区间会让幅值网格线挤成一团、
// 数据点也看不出位置(用户报告“单值信号的全局自适应不生效”)。
// 这里的留白与 `TabConfig::ComputeLaneRange` 的 `PadRange` 完全一致。
const double magnitude = std::max(1.0, std::fabs(low) * 0.1);
low -= magnitude;
high += magnitude;
}
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
@@ -4077,24 +4413,6 @@ namespace jdi
ApplyAllAutoFitToSelectionNow();
}
bool PlotView::AxisSparseInView(const TimeAxis& axis) const
{
if (m_plotRect.Width <= 1.0f)
{
return false;
}
// 单个采样点的时间轴不算“放大到稀疏”(它本来就处于最稀疏状态,默认样式已是点线);
// 若把这种情况也算进来,用户手动改成线段会被自动逻辑立刻改回去。
double pixels = 0.0;
const double center = (Tab().ViewMin() + Tab().ViewMax()) * 0.5;
if (!SampleSpacingPixels(axis, center, pixels))
{
return false;
}
// 与光标吸附的上门限同源:采样点间距超过半个绘图区宽度时,视界内只剩一两个采样点。
return pixels > 0.5 * static_cast<double>(m_plotRect.Width);
}
bool PlotView::UpdateAutoScatterStyles()
{
if (m_dataSetWritable == nullptr || m_tab == nullptr)
@@ -4114,8 +4432,9 @@ namespace jdi
continue;
}
const TimeAxis* axis = m_dataSetWritable->Axis(ref);
const SignalData* data = m_dataSetWritable->Resolve(ref);
SignalStyle* style = m_dataSetWritable->Style(ref);
if (axis == nullptr || style == nullptr)
if (axis == nullptr || data == nullptr || style == nullptr)
{
continue;
}
@@ -4123,14 +4442,17 @@ namespace jdi
// ⚠️ **先**判定“用户是否手动改过绘制方式”:本机制只会写入「点线」,
// 所以自动启用期间 mode 不等于「点线」只可能是用户改的。
// 这一步必须在下面的分支之前做 —— 用户改完样式可能**紧接着就缩出去**
// (例如直接点适应视图),那次调用的视界已经不稀疏,若等到那时才判定,
// (例如直接点适应视图),那次调用的视界已经不再满足条件,若等到那时才判定,
// 恢复逻辑会先把用户的修改覆盖掉(用户明确要求不能这样)。
if (style->autoScatterActive && style->mode != DrawMode::LINE_SCATTER)
{
style->autoScatterUserOverride = true;
}
if (AxisSparseInView(*axis))
// 判定与**光标吸附完全同源**(用户 2026-10-15 明确要求):
// 只有“一屏内数据点少于 N×4”的信号才自动切「点线」,
// 不再满足时(适应视图、或把视界移到信号的稠密区)自动恢复原样式。
if (SignalSnapEnabled(*axis, *data))
{
if (!style->autoScatterActive)
{
+25 -3
View File
@@ -443,7 +443,7 @@ namespace jdi
* @brief 判断哪个分组行需要「悬浮」在列表顶部(返回行下标,无需悬浮时返回 -1)。
*
* 判据:分组行已被滚出列表上边界(`RowRect(row).Y < m_listRect.Y`),
* 但该组内**仍有行留在列表里**(本组最后一行的下边界尚未滚过去)。
* 但该组内**仍有行留在列表里**(本组最后一行的下边界尚未越过列表上边界)。
* 两者同时成立时组名必须钉在最上方;整组都滚过去后组名跟随一起消失。
*
* @param [IN] rowCount 行总数。
@@ -452,9 +452,31 @@ namespace jdi
int StickyGroupRowIndex(int rowCount) const;
/**
* @brief 把分组行「钉在列表顶部」绘制(组名跟随滚动,直到整组滚过去)。
* @brief 取某分组行的**最后一行**下标(列表行按分组连续排列)。
* @param [IN] row 分组行下标。
* @param [IN] rowCount 行总数。
* @return 本组最后一行下标(即下一个分组行的上一行)。
*/
int GroupLastRowIndex(int row, int rowCount) const;
/**
* @brief 计算分组行悬浮时应绘制到的**顶部坐标**(含平滑滚出)。
*
* 用户要求(2026-10-15):组名不能突然消失,也不能盖住下一个组名。公式:
* `min(列表上边界, 本组最后一行下边界 − 组名高度)` ⇒
* * 本组还有行在列表里时钉在列表上边界;
* * 本组最后一行开始滑出上边界时,组名跟着一起上移、随滚动条自然离界;
* * 悬浮组名的下边界永远不超过下一个分组行的自然位置 ⇒ 不会遮挡下一组。
*
* @param [IN] row 分组行下标。
* @param [IN] rowCount 行总数。
* @return 悬浮绘制的顶部 Y 坐标。
*/
float StickyGroupRowTop(int row, int rowCount) const;
/**
* @brief 把分组行「按悬浮位置」绘制(组名跟随滚动,直到整组滚过去)。
*
* 用户要求长列表(例如上百个信号的 MF4)滚动时组名一直可见。
* ⚠️ 必须在常规行循环**之后**调用(它要盖在信号行之上),并且常规循环要**跳过**
* `row` 行,否则该行会被画两次。
*
+49 -19
View File
@@ -580,10 +580,25 @@ namespace jdi
return gd::RectF(m_listRect.X, top, m_listRect.Width, m_rows[static_cast<std::size_t>(row)].height);
}
int SignalPanel::GroupLastRowIndex(int row, int rowCount) const
{
// 列表行按分组连续排列 ⇒ 本组的最后一行就是下一个分组行之前的那一行。
int last = row;
for (int next = row + 1; next < rowCount; ++next)
{
if (m_rows[static_cast<std::size_t>(next)].kind == RowKind::GROUP)
{
break;
}
last = next;
}
return last;
}
int SignalPanel::StickyGroupRowIndex(int rowCount) const
{
// 「组名悬浮」:分组行被滚出列表上边界、而该组内仍有行留在列表里时,
// 把分组行钉在列表顶部,直到该组全部行都滚过去为止。
// 把分组行**钉在列表顶部**,直到该组全部行都滚过去为止。
// 用户要求:组名要一直可见,方便在长列表(例如上百个信号的 MF4)里辨认当前分组。
if (m_rowYOverrideActive || m_listRect.Height <= 0.0f)
{
@@ -601,19 +616,8 @@ namespace jdi
// 分组行本身仍在列表内(或更靠下):无需悬浮。
continue;
}
// 本组的最后一行(列表行按分组连续排列)。
int last = row;
for (int next = row + 1; next < rowCount; ++next)
{
if (m_rows[static_cast<std::size_t>(next)].kind == RowKind::GROUP)
{
break;
}
last = next;
}
const gd::RectF lastRect = RowRect(last);
if (lastRect.Y + lastRect.Height <= m_listRect.Y)
const int last = GroupLastRowIndex(row, rowCount);
if (RowRect(last).Y + m_rows[static_cast<std::size_t>(last)].height <= m_listRect.Y)
{
// 整组(含最后一行)已全部滚过去:组名跟随一起向上,不再出现。
continue;
@@ -623,6 +627,21 @@ namespace jdi
return -1;
}
float SignalPanel::StickyGroupRowTop(int row, int rowCount) const
{
// 悬浮位置(用户 2026-10-15 要求“平滑过渡”,不要突然消失):
// * 平时钉在列表上边界(`m_listRect.Y`);
// * 当**本组最后一行的下边界**上移越过悬浮组名的下边界时,组名跟着一起上移
// ⇒ 随滚动条自然滚出列表,而不是跳到位置后突然消失。
// 该公式同时保证「悬浮组名的下边界永远不超过本组最后一行的下边界」,
// 而后者就是**下一个分组行的自然位置** —— 因此紧挨着的下一个组名不可能被覆盖
//(旧实现固定钉在 `m_listRect.Y`,会把下一个组名盖住,让它的信号看起来属于上一组)。
const int last = GroupLastRowIndex(row, rowCount);
const float lastBottom =
RowRect(last).Y + m_rows[static_cast<std::size_t>(last)].height;
return std::min(m_listRect.Y, lastBottom - m_rows[static_cast<std::size_t>(row)].height);
}
void SignalPanel::PaintStickyGroupRow(gd::Graphics& g, int row)
{
if (row < 0 || row >= static_cast<int>(m_rows.size()) ||
@@ -631,16 +650,27 @@ namespace jdi
return;
}
const int rowCount = static_cast<int>(m_rows.size());
const float height = m_rows[static_cast<std::size_t>(row)].height;
const float naturalY = RowRect(row).Y;
const float stickyY = StickyGroupRowTop(row, rowCount);
if (stickyY + height <= m_listRect.Y + 0.5f)
{
return; // 已经整块滑出列表上边界
}
m_rowYOverrideActive = true;
m_rowYOverrideY = m_listRect.Y;
m_rowYOverrideY = stickyY;
// 悬浮行必须是不透明的,否则下面的信号行会与组名叠在一起看不清;
// `FillRowRect` 会把矩形夹到列表矩形(GDI 填充不受 GDI+ 裁剪约束)。
FillRowRect(g, gd::RectF(m_listRect.X, m_listRect.Y, m_listRect.Width, height), m_theme->panelBg);
FillRowRect(g, gd::RectF(m_listRect.X, stickyY, m_listRect.Width, height), m_theme->panelBg);
PaintFileRow(g, row);
// 底部一条边界线,让悬浮行与它下面滚动的信号行区分开。
UiDrawLine(g, m_listRect.X, m_listRect.Y + height - 0.5f, m_listRect.X + m_listRect.Width,
m_listRect.Y + height - 0.5f, m_theme->border, 1.0f);
// 只有真正“浮”在其它行之上时才补一条底部边界线(否则会与正常绘制有差异)。
if (stickyY > naturalY + 0.5f)
{
UiDrawLine(g, m_listRect.X, stickyY + height - 0.5f, m_listRect.X + m_listRect.Width,
stickyY + height - 0.5f, m_theme->border, 1.0f);
}
m_rowYOverrideActive = false;
}
+109 -8
View File
@@ -1579,6 +1579,89 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸(
---
### 9.30 组名平滑悬浮 + 单点信号初始视图 + 光标吸附统一判定(含自动点线同源)(本轮,起源设备)
> 本轮四项(用户 2026-10-15 第二轮报告):① 组名消失太突兀、且悬浮组名会盖住下一个组名
>(让人误以为后面的信号属于上一组);② 只有一个数据点的信号“全局自适应不生效”、
> 初始视图看不出它的状态;③ 光标吸附体验差且经常错误吸附,改用“一屏数据点 < N×4”的统一判定;
> ④ 放大后自动切点线没按预期工作,改为**与吸附判定完全同源**。
> 离屏验证 **55/55 通过**(连跑 3 次稳定),A/B 禁用三项机制后 **14 项如实失败**。
#### 一、组名悬浮:平滑滚出 + 不遮挡下一组(`SignalPanel`)
- 悬浮位置由 `StickyGroupRowTop(row, rowCount)` 给出:
**`min(列表上边界, 本组最后一行下边界 − 组名高度)`**。
* 本组还有行在列表里 ⇒ 钉在列表上边界;
* 本组最后一行开始滑出上边界 ⇒ 组名跟着一起上移、随滚动条**自然离界**(不再突然消失);
* 因为“本组最后一行的下边界”恰好就是**下一个分组行的自然位置**,所以悬浮组名的下边界
永远不超过下一个分组行 ⇒ **不可能遮住下一组名**(旧实现固定钉在 `m_listRect.Y` 会盖住它)。
- **折叠的分组不悬浮**:折叠时本组的“最后一行”就是它自己那一行,于是悬浮位置 = 它的自然位置
⇒ 随滚动自然划过(旧实现会让它停在最上方遮住下一组)。
- 底部边界线只在“真的浮在其它行之上”(`stickyTop > 自然位置`)时才画,避免与正常绘制有像素差。
- 常规行循环仍必须**跳过**被悬浮的那一行(否则同一行画两次)。
#### 二、单点信号的初始视图(`PlotView::FitSinglePointSignal`)
- 触发条件(用户明确要求):当前**没有任何信号被勾选**、勾选的第一个信号**只有一个有效数据点**
⇒ 旧路径(`FitAll` 的整段自适应)会让时间轴覆盖整条时间列、Y 轴退化成几乎为 0 的区间。
- 新行为:数据点**居中**,**时间跨度 = 1s**、**Y 轴跨度 = 1**(各为“一个大格”);
同时设 `autoRange = false`、`fitYToSelection = false`(否则会被自适应路径改回去)。
- `Application::SynchronizeView` 在首次勾选时先试 `FitSinglePointSignal()`,失败才 `FitAll()`。
- `AutoRangeLaneToView` 的**退化区间**(可见区间里只有一个取值)也改为 `PadRange` 同款留白
`max(1, |v|×0.1)`,不再给“几乎为 0”的区间(那会让幅值网格线挤成一团)。
- ⚠️ **需求作废**:上一轮“有效采样点 2…16 的信号默认改成点线 + 2px”**已取消**
(用户 2026-10-15 明确作废:下面第四项的“放大自动点线”已覆盖那个场景)。
只保留“**只有一个**有效采样点 → 点线 + 最大线宽”这一条。
- `SignalData::singlePointTime` 已升级为 **`sparseTimes` / `sparseValues`**
(有效点 ≤ `kSparseTimesLimit = 1024` 时在加载/插入时缓存全部有效点,按时间升序),
同时服务于:单点初始视图(取时刻与取值)、吸附判定(二分)、最近数据点查找。
#### 三、光标吸附的统一判定(`PlotView`)
- **N = `GridMinorCellCount()`**:网格底衬一横排的小格数,与 `PaintLane`/`PaintTimeAxis`
用**同一套** `NiceStep(span, kTimeTickTarget)` + 五等分算出(所以不随缩放等级大幅变化)。
- **`SignalSnapEnabled(axis, signal)`**(用户 2026-10-15 的定义):
**当且仅当**该信号在**当前一屏**内的数据点个数 `< N × 4` 时开启吸附;
判据随视界实时重算 ⇒ 疏密变化的信号“移到稀疏区自动吸附、移回稠密区自动自由移动”。
⚠️ 额外要求视界内**至少有一个可见数据点**(否则“0 < N×4”会把“数据全在视界之外”的信号
误判成稀疏,白切一次点线且没有任何可吸附对象)。
⚠️ 性能:总数不足阈值时直接返回(配合 `sparseTimes` 二分);否则只统计可见下标区间内的
有限值并**攒够阈值即早退**,扫描窗口还有 `threshold × 64` 的上限(防 NaN 长循环)。
- **`SnapTimeToSample`**:判定**只针对鼠标所在网格**(`LaneAtY(m_mouseY)`)——
同一个时间轴上,一个网格达到稀疏标准、另一个没有时,只在前者吸附;
同网格内**所有符合判定的信号**的数据点都参与比较。
**吸附半径 = 一个网格小格的宽度**(与判定同源),且**不超过半个局部采样间距**
⇒ 数据点很稀疏(一屏只有一两个点)时光标在点之间仍可自由移动(吸附 ≠ 卡住)。
⚠️ `LocalSampleSpacing` 对 `finiteCount <= 1` 必须返回 0(“数据点间距无定义”),
否则会拿时间轴的采样间隔充数(NaN 填充的轴上即 1ms)把半径压到极小、单点永远吸不住。
⚠️ 旧实现(`SampleSpacingPixels` 的“下门限 6px + 上门限半屏宽”)**已删除**,不要再恢复。
#### 四、自动点线:与吸附判定**完全同源**(`UpdateAutoScatterStyles`)
- 只有 `SignalSnapEnabled` 为真的信号自动切「点线」;不再满足时(适应视图、或视界从稀疏区
移到稠密区)自动恢复原样式;自动期间用户手动改过则不恢复(用户选择优先)。
- ⚠️ “用户是否手动改过”的判定必须在恢复判断**之前**(用户改完可能紧接着就退出阈值)。
- `AxisSparseInView`(旧的“采样间距 > 半屏宽”)**已删除**。
#### 五、验证(离屏 `.preflight/check`,`SW_HIDE` + 不注入真实输入 + 进程/线程 `BELOW_NORMAL`)
- **55/55 通过**(连跑 3 次稳定):`[A]` 组名悬浮(钉住 / 平滑上移 / 整组滚过后换下一组 /
折叠组不悬浮 / 悬浮下边界不超过下一组名)、`[B]` 单点初始视图(跨度 1s 与 1、居中、
样式为点线 + 最大线宽、两个信号时不再走单点路径)、`[C]` 吸附判定(满视图都关闭 /
稀疏窗口开启 / 无可见点不开启 / 同时间轴不同网格结果不同 / 同网格多信号的点都参与 /
稠密↔稀疏双向动态切换 / 单点靠近吸附远离自由)、`[D]` 自动点线(同源判定 /
恢复 / 用户手动改过不覆盖)。
- **A/B**:临时禁用「悬浮位置公式 / 单点初始视图 / 吸附判定」后重跑 ⇒ **14 项如实失败**。
- ⚠️ 测试手法坑(本轮踩到并修掉):① 每个用例前 `ClearGroups`,否则行号/网格号不确定;
② **测试夹具的时间列必须单调升序** —— 否则时间轴被判为非单调、`VisibleIndexRange` 退化成整段,
于是“稀疏窗口”里会把视界外的稠密点也数进去(本轮的 `mixed` 信号就是这样被误判的);
③ `AddGroup` 会 push 进 `vector<SignalGroup>`,之前缓存的 `SignalData*`/`TimeAxis*`
引**用可能失效**,用完要重新 `Resolve`/`Axis`;
④ 改完 `PlotView.cpp` 后要确认 `make release` 真的重编译了(曾遇时间戳未更新而链接到旧 `.o`,
症状恰好等于“修复没生效”——先 `(Get-Item …).LastWriteTime = Get-Date` 再 make)。
---
## 10. 变更日志(本文件维护记录)
| 日期 | 设备/模型 | 摘要 |
@@ -1621,6 +1704,7 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸(
| 2026-10-15 | DeepSeek-V4.1-Flash(**另一台设备**) | **数据层大重构(文件→分组 / 时间轴一等公民 / 加载器抽象)+ CSV 多时间轴 + MF4 支持(详见 §9.27 与 `Documents/refactor_report_2026-10-15.md`)**。① **解耦**:`DataFile`→**`SignalGroup`**(删除 `filePath`/`fileName`/单一 `time`/`timeColumnName`/`timeMonotonic`/`minTime`/`maxTime`),新增 **`TimeAxis`**(1..N 条/组)与 `SignalData::axisIndex`,`SignalData` 删除冗余的 `rawHeader` 与**全仓无人使用的 `unit`**;`SignalRef::fileIndex`→`groupIndex`;`DataSet`/`TabConfig` 全套改名并新增 `Axis(ref)`;采样/统计 API(`SampleSignalAt`/`FindNearestSample`/`VisibleIndexRange`/`EdgeIndexRange`/`EdgeCountsFor`/`EdgeCenterCountsFor`/`EdgeScanFor`/`MeanFilterValueAt`/`WeakPeakValueAt`/`SampleSpacingPixels`)**首参由 `const DataFile&` 改为 `const TimeAxis&`**;`ResolvedSignal` 携带 `{group, axis}`。② **加载器抽象**:新增 `DataTable.hpp`(`DataTable`/`LoadProgress`/`TableBlock`/`LoadedSource`)、`DataLoader.*`(唯一入口 `LoadSignalSource` 按扩展名分发)、`CsvLoader.*`、`Mf4Loader.*`,删除 `CsvTable.*`;唯一装配点 `BuildSignalGroup` 与格式无关(新增格式只写加载器)。③ **CSV 多时间轴**:每个 `time` 列开启新块、各块按自己的时间列裁剪 ⇒ 支持各轴不等长(实测 3 条轴 7/4/7),**不再把其它 `time` 列当成信号**;时间轴判据收紧。④ **MF4 支持**:加载后新建分组、组名=文件名,全文件信号归入该组;每个通道组(CG)一条时间轴并自动去重(实测 27 CG → 3 轴 / 124 信号);实现 MDF v4 块解析(DG/CG/CN/CC/DT)、位级整数与 32/64 位浮点解码、线性换算 `phys = cc_val[0] + cc_val[1]*raw`、无效位处理;**`##TX` 兼容规范的「长度+UTF-16」与 CANape 的「裸 NUL 结尾文本」**(否则通道名全空)。⑤ 顺带修复:`Application::Initialize` 在窗口类已注册时不再弹模态框(旧实现导致同进程二次构造永久阻塞),`Shutdown` 注销窗口类;UI 文案统一到「分组」,对话框支持 `*.mf4/*.mdf`。验证:新增 `datacheck` **69/69**、`edgecheck` **46/46**(替代被编码破坏的 `panelcheck`)、既有 `verify` **47/47**(另 2 条本机旧规则断言)、`make release` 零警告。 |
| 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` 零警告。 |
---
@@ -1838,14 +1922,31 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸(
(名称框 / ✕ / 徽标)的唯一来源,因此「组名悬浮」只需给它加一个顶部坐标覆盖
(`m_rowYOverrideActive/m_rowYOverrideY`)即可整体跟随;并且**常规行循环必须跳过被钉住的那一行**,
否则同一行会被画两次(一次在滚动位置、一次在顶部)。
58. ⚠️ **「少数数据点」信号的默认样式**(用户明确要求):有效采样点 **1 个** → 「点线」+
**最大线宽(10px)**(单点就是一个小圆点,半径与线宽成正比);**2 … 16 个** → 「点线」+ 2px;
其余保持「线段」+ 1px。只作用于**新建**信号(不覆盖用户设置)。
⚠️ 线宽范围常量(`kSignalLineWidthMin/Max`)必须**单一来源**(`DataModel.hpp`),
信号面板滑块要引用它,否则「最大线宽」会在两处漂移。
⚠️ 单点信号的光标吸附是**半径判据**(`kCursorSnapPointRadiusPixels = 8` 逻辑像素),
半径外必须自由移动 —— 是「吸附」不是「卡住」;其时刻由 `SignalData::singlePointTime`
在加载时缓存(不要改成鼠标移动时线性扫描采样)。
58. ⚠️ **光标吸附的判定只有一套定义(§9.30)**:某信号在当前一屏内的数据点个数
`< N × 4`(N = `GridMinorCellCount()`,网格一横排的小格数,与网格绘制同源)时
才为它开启吸附;判据**随视界实时重算**(疏密变化的信号自动在稀疏区吸附、稠密区自由)。
⚠️ 额外要求视界内**至少有一个可见数据点**;
⚠️ 判定**只针对鼠标所在网格**(`SnapTimeToSample` 用 `LaneAtY(m_mouseY)`);
⚠️ 吸附半径 = **一个网格小格宽**,且**不超过半个局部采样间距**(吸附 ≠ 卡住光标);
⚠️ 旧的 `SampleSpacingPixels`(6px 下门限 + 半屏宽上门限)**已删除,不要再恢复**。
⚠️「放大自动切点线」必须**与吸附判定完全同源**(`UpdateAutoScatterStyles` 调
`SignalSnapEnabled`),不要再引入“采样间距 > 半屏宽”之类的独立判据。
⚠️ `SignalData` 用 **`sparseTimes` / `sparseValues`**(有效点 ≤ 1024 时缓存全部有效点)
支撑吸附与单点初始视图;**不要**在鼠标移动时扫描整条采样数组。
⚠️ `LocalSampleSpacing` 对 `finiteCount <= 1` 必须返回 0(数据点间距无定义),
否则单点信号永远吸不住。
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. ⚠️ **组名悬浮的位置公式(勿改回“固定钉在列表上边界”)**:
`min(列表上边界, 本组最后一行下边界 − 组名高度)` ⇒
组名随最后一行**平滑滚出**(不突然消失),且下边界永不越过下一个分组行的自然位置
⇒ **不可能遮住下一个组名**;**折叠的分组不悬浮**(随滚动自然划过)。
行几何仍只由 `RowRect()` 提供(用 `m_rowYOverrideActive/m_rowYOverrideY` 覆盖顶部坐标)。
### 11.3 离屏验证方法学(本机偏好,强烈建议沿用)
+24 -17
View File
@@ -35,25 +35,26 @@ namespace jdi
constexpr float kSignalLineWidthMin = 0.5f;
constexpr float kSignalLineWidthMax = 10.0f;
/**
* @brief 「少数数据点信号」的有效采样点数量上限(含)。
*
* 有效采样点不超过该值的信号,初始样式直接使用「点线」(`LINE_SCATTER`)并加粗到
* `kSparseSignalLineWidth`:采样点这么少时线段模式几乎什么也看不见
*(只有一个点时甚至完全画不出来)。正常密度的信号保持「线段 + 1px」。
*/
constexpr std::size_t kSparseSignalSampleLimit = 16;
/** @brief 稀疏信号(有效采样点 2 … `kSparseSignalSampleLimit`)的初始线宽。 */
constexpr float kSparseSignalLineWidth = 2.0f;
/**
* @brief **只含一个有效采样点**的信号的初始线宽(取样式允许的最大线宽)。
*
* 单个数据点在「点线」模式下就是一个圆点,半径与线宽成正比;取最大值才能看清。
* ⚠️ 曾经还对「有效采样点 2…16」的信号也设了「点线 + 2px」的默认样式,
* 该需求已**作废**(用户 2026-10-15 明确取消):放大后自动切「点线」的机制
* 已经覆盖了那个场景,无需再改默认样式。
*/
constexpr float kSinglePointSignalLineWidth = kSignalLineWidthMax;
/**
* @brief 「稀疏信号」缓存有效采样点时刻/取值的上限(有效点不超过该值才缓存)。
*
* 光标吸附需要“当前视界内有多少个数据点”以及“离光标最近的那个数据点”。
* 对有效点很少的信号(典型:单点信号、只有零星采样的列)事先把它们的时刻与取值缓存下来,
* 判定与查找都退化成二分 → 不必在鼠标移动时扫描整条采样数组(千万级信号无法承受)。
* 1024 个有效点 × 16 字节 ≈ 16 KB/条,可忽略。
*/
constexpr std::size_t kSparseTimesLimit = 1024;
/**
* @brief 绘图区拖拽缩放模式(由工具栏按钮切换)。
*/
@@ -354,13 +355,19 @@ namespace jdi
std::size_t finiteCount = 0; /**< 有限采样点数量。 */
/**
* @brief 只含**一个**有效采样点时的该点时刻(不存在该情况时为 NaN)。
* @brief 有效采样点很少时的**全部有效点**(按时间升序;有效点很多时为空)。
*
* 在加载/插入信号时与 `ComputeStats` 同一趟扫描就地填充,供光标吸附使用:
* 只有一个数据点的信号,光标在其它区域自由移动、靠近该点时吸附过去
*(吸附而非“卡住”)。没有这个缓存就只能在鼠标移动时线性扫描采样值,大信号下不可接受。
* 仅当 `finiteCount <= kSparseTimesLimit` 时在加载/插入信号时就地填充,用于:
* * 光标吸附:直接二分得到“视界内有几个数据点”“离光标最近的数据点”;
* * 单点信号的初始视图:Centering 需要那个点的时刻与取值。
* ⚠️ 不要在鼠标移动时改成扫描整条采样数组来找有效点(千万级信号承受不住)。
*
* 说明:`sparseTimes.size() == 1` 即“只有一个数据点的信号”。
*/
double singlePointTime = std::numeric_limits<double>::quiet_NaN();
std::vector<double> sparseTimes;
/** @brief `sparseTimes` 对应的取值(同序)。 */
std::vector<double> sparseValues;
/**
* @brief 粗粒度包络层:每 coarseStride 个采样一组的 min/max。
+71 -37
View File
@@ -370,34 +370,86 @@ namespace jdi
double minValue = 0.0; /**< 有限采样值最小值。 */
double maxValue = 0.0; /**< 有限采样值最大值。 */
std::size_t finiteCount = 0; /**< 有限采样点数量。 */
std::size_t firstFiniteIndex = 0; /**< 第一个有限采样点的下标(无有效值时无意义)。 */
};
/**
* @brief 按有效采样点数量设置「稀疏信号」的**初始**绘制样式。
* @brief 为**只有一个有效采样点**的信号设置初始绘制样式。
*
* 有效采样点极少时线段模式几乎什么也看不见(只有一个点时更是完全画不出东西),
* 因此这类信号初始就用「点线」并适当加粗:
* * **只有一个有效采样点** → 点线 + `kSinglePointSignalLineWidth`(最大线宽):那个点本身
* 就是一个圆点,半径与线宽成正比,不取最大值根本看不清;
* * **有效采样点 2 … `kSparseSignalSampleLimit`** → 点线 + `kSparseSignalLineWidth`(2px)。
* 单个数据点在「线段」模式下完全画不出来,因此初始就用「点线」并取**最大线宽**
* (点线模式下数据点是一个圆点,半径与线宽成正比)。
*
* 其余信号保持既有默认(线段 + 1px)。只在**新建信号**时调用(用户改过样式后不再覆盖)。
* ⚠️ 曾经还对「有效采样点 2…16」的信号也改用「点线 + 2px」—— 该需求已**作废**
*(用户 2026-10-15 明确取消:放大后自动切点线的机制已覆盖那个场景)。
*
* 只在**新建信号**时调用(用户改过样式后不再覆盖)。
*
* @param [IN] finiteCount 有效采样点数量。
* @param [IN,OUT] style 待初始化的显示外观。
*/
void ApplySparseDefaultStyle(std::size_t finiteCount, SignalStyle& style)
void ApplySinglePointDefaultStyle(std::size_t finiteCount, SignalStyle& style)
{
if (finiteCount == 1)
{
style.mode = DrawMode::LINE_SCATTER;
style.lineWidth = kSinglePointSignalLineWidth;
}
else if (finiteCount <= kSparseSignalSampleLimit)
}
/**
* @brief 缓存「有效采样点很少」的信号的**全部有效点**(时刻 + 取值,按时间升序)。
*
* 供光标吸附(二分查找最近数据点、O(log n) 判定稀疏)与单点信号的初始视图使用。
* 有效点很多时(> `kSparseTimesLimit`)不做任何缓存(用不到,也没必要占内存)。
*
* @param [IN] axis 该信号使用的时间轴。
* @param [IN,OUT] signal 目标信号。
*/
void CacheSparseSamples(const TimeAxis& axis, SignalData& signal)
{
signal.sparseTimes.clear();
signal.sparseValues.clear();
if (signal.finiteCount == 0 || signal.finiteCount > kSparseTimesLimit)
{
style.mode = DrawMode::LINE_SCATTER;
style.lineWidth = kSparseSignalLineWidth;
return;
}
const std::size_t count = std::min(axis.time.size(), signal.values.size());
signal.sparseTimes.reserve(signal.finiteCount);
signal.sparseValues.reserve(signal.finiteCount);
for (std::size_t index = 0; index < count; ++index)
{
const double value = signal.values[index];
const double time = axis.time[index];
if (!std::isfinite(value) || !std::isfinite(time))
{
continue;
}
signal.sparseTimes.push_back(time);
signal.sparseValues.push_back(value);
}
// 非单调时间轴:按时间排序,保证后续二分查找合法
//(单调时间轴的采样本身就是升序,无需排序)。
if (!axis.monotonic && signal.sparseTimes.size() > 1)
{
std::vector<std::size_t> order(signal.sparseTimes.size());
for (std::size_t index = 0; index < order.size(); ++index)
{
order[index] = index;
}
std::stable_sort(order.begin(), order.end(), [&signal](std::size_t a, std::size_t b)
{ return signal.sparseTimes[a] < signal.sparseTimes[b]; });
std::vector<double> times;
std::vector<double> values;
times.reserve(order.size());
values.reserve(order.size());
for (const std::size_t index : order)
{
times.push_back(signal.sparseTimes[index]);
values.push_back(signal.sparseValues[index]);
}
signal.sparseTimes = std::move(times);
signal.sparseValues = std::move(values);
}
}
@@ -435,10 +487,6 @@ namespace jdi
const double value = values[index];
if (std::isfinite(value))
{
if (stats.finiteCount == 0)
{
stats.firstFiniteIndex = index; // 供「单点信号」缓存其时刻
}
++stats.finiteCount;
low = std::min(low, value);
high = std::max(high, value);
@@ -619,15 +667,8 @@ namespace jdi
signal.minValue = stats.minValue;
signal.maxValue = stats.maxValue;
signal.finiteCount = stats.finiteCount;
// 只含一个有效采样点时缓存它的时刻(光标吸附用)。
if (stats.finiteCount == 1)
{
const TimeAxis& ownAxis = group.axes[std::min(axisIndex, group.axes.size() - 1)];
if (stats.firstFiniteIndex < ownAxis.time.size())
{
signal.singlePointTime = ownAxis.time[stats.firstFiniteIndex];
}
}
// 有效点很少时缓存全部有效点(光标吸附 / 单点信号初始视图用)。
CacheSparseSamples(group.AxisAt(signal.axisIndex), signal);
// 显示外观随信号一起初始化:调色板按「全局信号序号」取色,
// 使同一信号在任意标签页中都保持同一默认颜色。
@@ -651,8 +692,8 @@ namespace jdi
signal.style.opacity = 1.0f;
signal.style.lineWidth = 1.0f;
signal.style.mode = DrawMode::LINE;
// 采样点极少的信号(含只有一个点)改用「点线」并加粗,否则几乎不可见。
ApplySparseDefaultStyle(stats.finiteCount, signal.style);
// 只有一个有效采样点的信号改用「点线」+ 最大线宽,否则根本看不见。
ApplySinglePointDefaultStyle(stats.finiteCount, signal.style);
}
group.signals.push_back(std::move(signal));
@@ -717,15 +758,8 @@ namespace jdi
signal.minValue = stats.minValue;
signal.maxValue = stats.maxValue;
signal.finiteCount = stats.finiteCount;
// 只含一个有效采样点时缓存它的时刻(光标吸附用);派生信号沿用源信号的时间轴。
if (stats.finiteCount == 1 && !group.axes.empty())
{
const TimeAxis& ownAxis = group.AxisAt(signal.axisIndex);
if (stats.firstFiniteIndex < ownAxis.time.size())
{
signal.singlePointTime = ownAxis.time[stats.firstFiniteIndex];
}
}
// 有效点很少时缓存全部有效点;派生信号沿用源信号的时间轴。
CacheSparseSamples(group.AxisAt(signal.axisIndex), signal);
// 2. 显示外观按「全局信号序号」取调色板颜色(与普通新信号完全一致的规则)。
const int insertPos = afterSignalIndex + 1;
@@ -748,7 +782,7 @@ namespace jdi
}
// 采样点极少的派生信号同样改用「点线」并加粗(与普通加载路径同一规则)。
ApplySparseDefaultStyle(stats.finiteCount, signal.style);
ApplySinglePointDefaultStyle(stats.finiteCount, signal.style);
}
group.signals.insert(group.signals.begin() + insertPos, std::move(signal));