组名平滑悬浮 + 单点信号初始视图 + 光标吸附统一判定(自动点线同源)
一、信号列表「组名悬浮」平滑化、且不再遮住下一个组名
* 悬浮位置改为 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:
1 parent
9f6de3bba4
commit
57cc714c1d
8 files changed
+808
-230
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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 表示放大。
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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` 行,否则该行会被画两次。
|
||||
*
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user