全局 Y 轴自适应跟随网格结构 + 组名悬浮 + 稀疏信号默认点线 + 单点光标吸附 + 放大自动点线

一、全局「Y 轴自适应」跟随网格结构
 * 新勾选信号产生的新网格现在会**继承**该开关:RebuildLanes 重建前先算
   previousAllAutoFit(非空且每个网格都开着 = 全局开启的正式定义),LaneCarry 加
   matched 标记,新出现的信号用它作为 fitYToSelection 的默认值。
 * 新增 PlotView::ReapplyGlobalAutoFitIfEnabled(),在 Layout()(网格数量变化 →
   Relayout)、RefreshLanes()(信号数量变化)、SplitLane() 之后立即重算一次。
   ⇒ 新勾选(按当前视界自适应)、多信号网格内取消显示某信号(剩余信号重新收敛)、
     拆分网格(每份各自自适应)三项都覆盖。

二、信号列表「组名悬浮」
 * 分组行被滚出列表上边界、而该组仍有信号留在列表里时,把分组行钉在列表顶部
   (不透明底 + 底部边界线);整组翻过去后本组组名消失,改由下一组悬浮。
 * 新增 SignalPanel::StickyGroupRowIndex() / PaintStickyGroupRow();RowRect 增加
   m_rowYOverrideActive/m_rowYOverrideY 覆盖行顶部坐标(RowRect 是行内名称框 / ✕ /
   徽标的唯一几何来源)。常规行循环跳过被钉住的那一行,避免同一行被画两次。

三、「少数数据点」信号的默认样式
 * 有效采样点 1 个 → 「点线」+ 最大线宽(10px);2 … 16 个 → 「点线」+ 2px;
   其余保持「线段」+ 1px。只在新建信号时生效(BuildSignalGroup 与
   InsertDerivedSignal),不覆盖用户设置。
 * 线宽范围常量统一到 DataModel.hpp(kSignalLineWidthMin/Max),SignalPanel 的线宽
   滑块改为引用它们(原本两处各自硬编码,容易漂移)。

四、单点信号的光标吸附(吸附而非卡住)
 * 旧逻辑要求时间轴至少 2 个采样点才能算「采样间距」,单点信号因此永远不吸附。
 * 新增 SignalData::singlePointTime(加载时与 ComputeStats 同趟扫描缓存该点时刻),
   SnapTimeToSample 新增半径分支(kCursorSnapPointRadiusPixels = 8 逻辑像素):
   半径内吸附到该点,半径外自由移动(移开即自由,不是把光标吸住)。

五、放大到「采样点稀疏」时自动启用点线
 * SignalStyle 新增运行态三字段:autoScatterActive / autoScatterSavedMode /
   autoScatterUserOverride。
 * AxisSparseInView()(采样点间距 > 半个绘图区宽度,与光标吸附上门限同源;单点时间轴
   不算稀疏)+ UpdateAutoScatterStyles():进入阈值保存原绘制方式并切「点线」,
   退出阈值(如适应视图)恢复;平移不改变缩放等级 ⇒ 结果稳定,不会样式抖动。
 * 自动启用期间用户手动改了绘制方式则退出时**不恢复**(尊重用户选择);该判定必须在
   恢复判断之前完成 —— 用户改完样式可能紧接着就退出阈值,否则会被覆盖。
 * 钩子:SetTimeView(视界唯一写入口)与 Layout / RefreshLanes / SplitLane。

六、验证(离屏 .preflight/check:SW_HIDE 窗口、不注入真实鼠标键盘、进程/线程 BELOW_NORMAL)
 65/65 通过(连跑 3 次稳定):[A1]/[A2]/[A3] 全局自适应、[B] 组名悬浮(含钉住行与自然
 位置逐像素一致、整组翻过后组名消失)、[C] 稀疏默认样式边界(1/2/5/16/17/1000 点)、
 [D] 单点吸附(半径内吸附、半径外自由、无残留粘滞)、[E] 自动点线(进入/退出/平移稳定/
 用户手动改过后不覆盖/再次进入再启用)。
 A/B:临时禁用六个机制后重跑 ⇒ 21 项如实失败(覆盖全部六项),证明断言有判别力。
 make release 零警告(用户正运行 Target.exe,链接用临时 -o 名字验证通过)。

七、规则同步
 Documents/agent_mem.md 新增 §9.29、§11.2 第 55–58 条硬约束与变更日志。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-10-15 14:10:13 +08:00
1 parent 3bca3b1201
commit 9f6de3bba4
7 files changed
+559 -5

No files matched your search

+173
View File
@@ -123,6 +123,13 @@ namespace jdi
}
const float kCursorSnapMinPixels = 6.0f; /**< 光标吸附采样点的最小采样间隔(逻辑像素)。 */
/**
* @brief 「只有一个数据点」的信号的光标吸附半径(逻辑像素)。
*
* 这类信号没有“采样间距”可依据,因此改用**半径判据**:光标距该点不超过该半径时吸附过去,
* 其它区域自由移动(用户明确要求“吸附而不是卡住光标”,只是一个提示性响应)。
*/
const float kCursorSnapPointRadiusPixels = 8.0f;
const float kLaneLabelPad = 6.0f; /**< 网格内侧标尺带的内边距。 */
const float kLegendHeight = 19.0f; /**< 图例条目高度(逻辑像素)。 */
const float kLegendPad = 5.0f; /**< 图例相对网格左上角的内缩。 */
@@ -968,11 +975,27 @@ namespace jdi
m_plotRect.Y, kScrollBarThickness * m_scale, m_plotRect.Height);
BuildLaneVisuals();
// 布局变化带上网格结构变化(`Relayout` 在网格数量变化时被调用):
// 新的网格要按当前视界自适应(全局开关开启时),稀疏放大状态也可能随绘图区宽度变化。
ReapplyGlobalAutoFitIfEnabled();
if (UpdateAutoScatterStyles())
{
InvalidateSceneCache();
}
}
void PlotView::RefreshLanes()
{
BuildLaneVisuals();
// 信号可见性变化后会走这里(网格数量不变的情形):
// ① 新出现的信号若继承到「Y 轴自适应」,需要立即按当前视界算一次;
// ② 多信号网格内取消显示某条信号后,剩余信号要重新收敛(旧区间含已移除的信号)。
ReapplyGlobalAutoFitIfEnabled();
if (UpdateAutoScatterStyles())
{
InvalidateSceneCache();
}
}
const SignalRef& PlotView::SelectedRef() const
@@ -1208,6 +1231,15 @@ namespace jdi
Tab().ViewMin() = low;
Tab().ViewMax() = high;
ClampTimeView();
// 视界是「自动点线提示」的驱动量:放大到采样点稀疏时自动切点线,退出阈值时恢复。
// 所有缩放/平移都经本函数(视界唯一写入口),因此这是唯一需要的接入点;
// 平移时缩放等级不变,结果稳定,不会产生样式抖动。
if (UpdateAutoScatterStyles())
{
// 绘制方式属于内容指纹,场景/装饰缓存需立即作废(面板可能正显示旧样式)。
InvalidateSceneCache();
}
}
void PlotView::ZoomTimeAt(double factor, double anchorTime)
@@ -1633,6 +1665,13 @@ namespace jdi
// 网格数量变化 → 几何与滚动范围需要重建,静态场景缓存也要失效。
BuildLaneVisuals();
InvalidateSceneCache();
// 拆出来的每个网格都继承了原网格的「Y 轴自适应」开关;全局开启时按当前视界重算一次
// (原来的幅值区间是按“多条信号叠在一起”算的,拆开后每条信号都应铺满自己的网格)。
ReapplyGlobalAutoFitIfEnabled();
if (UpdateAutoScatterStyles())
{
InvalidateSceneCache();
}
return true;
}
@@ -1704,6 +1743,39 @@ namespace jdi
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 SignalGroup& group : m_dataSet->Groups())
{
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;
}
}
}
}
for (const SignalGroup& group : m_dataSet->Groups())
{
// 只有采样点在屏幕上足够稀疏时才吸附,否则缩小视图后光标会“跳格”。
@@ -3989,6 +4061,107 @@ namespace jdi
}
}
void PlotView::ReapplyGlobalAutoFitIfEnabled()
{
if (m_tab == nullptr || m_dataSet == nullptr)
{
return;
}
// 与工具栏「Y 轴自适应」按钮的勾选判据完全一致(全部网格都开启才叫“全局开启”)。
if (!AllLanesAutoFitToSelection())
{
return;
}
// 结构变化后重新匹配一次:新网格要按当前视界自适应,
// 移除信号后的网格也要按剩下信号重新收敛(旧幅值区间是按旧信号集合算的)。
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)
{
return false;
}
bool changed = false;
// 只处理当前标签页里**可见**的信号:绘制方式的自动切换是「视界」驱动的,
// 看不见的信号与当前视界无关(它们可见时会经 `RefreshLanes` 再判一次)。
for (const Lane& lane : m_tab->Lanes())
{
for (const SignalRef& ref : lane.signals)
{
if (!m_tab->IsVisible(ref))
{
continue;
}
const TimeAxis* axis = m_dataSetWritable->Axis(ref);
SignalStyle* style = m_dataSetWritable->Style(ref);
if (axis == nullptr || style == nullptr)
{
continue;
}
// ⚠️ **先**判定“用户是否手动改过绘制方式”:本机制只会写入「点线」,
// 所以自动启用期间 mode 不等于「点线」只可能是用户改的。
// 这一步必须在下面的分支之前做 —— 用户改完样式可能**紧接着就缩出去**
// (例如直接点适应视图),那次调用的视界已经不稀疏,若等到那时才判定,
// 恢复逻辑会先把用户的修改覆盖掉(用户明确要求不能这样)。
if (style->autoScatterActive && style->mode != DrawMode::LINE_SCATTER)
{
style->autoScatterUserOverride = true;
}
if (AxisSparseInView(*axis))
{
if (!style->autoScatterActive)
{
// 进入阈值:保存原绘制方式并自动切到「点线」提示用户数据点位置。
style->autoScatterSavedMode = style->mode;
style->autoScatterActive = true;
style->autoScatterUserOverride = false;
if (style->mode != DrawMode::LINE_SCATTER)
{
style->mode = DrawMode::LINE_SCATTER;
changed = true;
}
}
}
else if (style->autoScatterActive)
{
// 退出阈值(例如适应视图):恢复自动启用之前的绘制方式;
// 但用户手动改过时**不恢复**(尊重用户的选择)。
style->autoScatterActive = false;
if (!style->autoScatterUserOverride && style->mode != style->autoScatterSavedMode)
{
style->mode = style->autoScatterSavedMode;
changed = true;
}
style->autoScatterUserOverride = false;
}
}
}
return changed;
}
void PlotView::ShiftLaneRange(int laneIndex, double deltaValue)
{
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_lanes.size()) ||