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

一、信号列表「组名悬浮」平滑化、且不再遮住下一个组名
 * 悬浮位置改为 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

+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));