修复信号面板滚动条跟随、工具栏连点丢状态、图层竖向网格线步长、多网格单点自适应与单点信号拖拽

用户 2026-10-15 报告的五个问题,逐一给出根因与修法(详见 Documents/agent_mem.md §9.32):

1. 拖动信号面板滚动条、鼠标移到绘图区后“滑块卡在原地不动”
   根因:Application::WM_MOUSEMOVE 的 plotOnlyMove 只看 inPlot && m_mouseOverPlot,
        拖滚动条进入绘图区后判定“本帧只影响绘图区” ⇒ 面板滚动偏移已变却从不重绘。
   修法:新增 draggingNonPlot(正在拖非绘图区组件时一律走完整失效)。

2. 工具栏快速连点 / 双击丢状态
   根因:(a) 窗口类带 CS_DBLCLKS,快速第二次按下被合成 WM_LBUTTONDBLCLK 而工具栏未处理
             (那一次按下完全丢失);
        (b) HandleMouseMove 只要鼠标不在工具栏上就调 OnMouseLeave(),清掉 m_pressedIndex
             ⇒ 按住期间从按钮边缘划出再回到按钮松手不会触发任何动作。
   修法:WM_LBUTTONDBLCLK 命中工具栏时按“按下”处理;m_activeTarget == TOOLBAR 期间不调 OnMouseLeave()。

3. 网格叠底:拖拽期间竖向网格线错位 / 丢失,操作信号样式时“闪烁”
   根因:PaintLane 的竖向主刻步长由 Tab().ViewMin/ViewMax 推出,而渲染信号图层时该区间已被
        RenderLaneLayerRegion 改成“宽窗口”(≈2× 前台)⇒ 图层辅助线步长 2×、位置与前台不同、
        “与主刻重合则跳过”的判据也随之错位 ⇒ 贴图后网格线错位 / 整条丢失,
        并在“图层帧 ↔ 结算帧”之间来回切换时闪烁。
   修法:新增 m_renderTimeStep,由 RenderLaneLayerRegion 用前台视界跨度算好传入
        (与纵向 m_wideTickSpan/m_wideTickHeight 同一思路),PaintLane 优先使用它。

4. 多个网格、每个网格只有一个数据点时「适应视图」把数据点甩飞
   修法:新增 FitSinglePointLanes(),按用户给出的规则:
        * 左右边界 = 所有网格中最左 / 最右数据点的时间,再向外各偏“一个网格小格”的时间跨度;
        * 逐网格 Y 自适应,并在纵向上下各留“一个 Y 小格”的高度;
        * 全部数据点在同一时刻时 ⇒ 时间跨度 1s 且以该时刻居中(与首个单点信号的初始视图对齐);
        * 某网格只有一个取值 ⇒ 该网格 Y 跨度 1 且居中。
        仅在全部可见信号都是单点(finiteCount == 1)时启用,否则交回常规 FitAll。

5. 单点信号的悬停高亮与拖拽换网格
   修法:HitTestSignal 新增单点分支(SinglePointScreenPos + 容差 = max(常规容差, 该点绘制半径));
        悬停用环状光晕且不改鼠标形状(QueryCursor 不查 HitTestSignal),按下拖拽后才改 IDC_SIZEALL;
        拖拽虚影对单点信号回退为实心点 + 环。

验证(全部离屏,不占用鼠标 / 键盘 / 屏幕):
- .preflight/check(SW_HIDE + 只注入合成消息 + 进程/线程 BELOW_NORMAL)**67/67 通过**,连跑 3 次稳定。
  其中 [C] 直接量“宽基底图层位图自身”的网格线间距(图层主刻 123px vs 前台 122px、辅助线 25px vs 25px),
  并断言拖拽帧确实来自宽基底贴图路径(m_wideLastValid)。
- A/B 判别:回退上述六处修复后 **17 项如实失败**([C] 辅助线 49px vs 25px、20 vs 40 条、最大偏移 539px)。
- make release:三个改动文件重新编译 **零警告**;链接用临时 `make release TARGET_FILE_NAME=Target_check` 通过。

同步记忆:Documents/agent_mem.md 新增 §9.32、§11.2 第 64–68 条硬约束、变更日志末行。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-10-15 17:58:33 +08:00
1 parent 68d8b7ede6
commit aee3a388b5
5 files changed
+541 -40

No files matched your search

+37 -3
View File
@@ -1479,8 +1479,17 @@ namespace jdi
bool changed = false; bool changed = false;
changed = changed =
(m_tabBar.HitTest(x, y) ? m_tabBar.OnMouseMove(x, y) : m_tabBar.OnMouseLeave()) || changed; (m_tabBar.HitTest(x, y) ? m_tabBar.OnMouseMove(x, y) : m_tabBar.OnMouseLeave()) || changed;
changed = // ⚠️ 按住工具栏按钮期间**绝不能**走 `OnMouseLeave()`:它会清掉按下态(`m_pressedIndex`),
(m_toolbar.HitTest(x, y) ? m_toolbar.OnMouseMove(x, y) : m_toolbar.OnMouseLeave()) || changed; // 于是随后在按钮上松手时什么也不会发生 —— 这正是“快速点击工具栏按钮时状态没切换”
// 的另一个成因(鼠标带着按下态从按钮边缘移出时会命中这里)。
if (m_toolbar.HitTest(x, y))
{
changed = m_toolbar.OnMouseMove(x, y) || changed;
}
else if (m_activeTarget != Target::TOOLBAR)
{
changed = m_toolbar.OnMouseLeave() || changed;
}
changed = (m_panel.HitTest(x, y) ? m_panel.OnMouseMove(x, y) : m_panel.OnMouseLeave()) || changed; changed = (m_panel.HitTest(x, y) ? m_panel.OnMouseMove(x, y) : m_panel.OnMouseLeave()) || changed;
changed = (m_plot.HitTest(x, y) ? m_plot.OnMouseMove(x, y) : m_plot.OnMouseLeave()) || changed; changed = (m_plot.HitTest(x, y) ? m_plot.OnMouseMove(x, y) : m_plot.OnMouseLeave()) || changed;
return changed; return changed;
@@ -2476,8 +2485,17 @@ namespace jdi
// 仅当本次移动不会影响绘图区之外的区域时,才跳过装饰区域的重绘: // 仅当本次移动不会影响绘图区之外的区域时,才跳过装饰区域的重绘:
// 正在拖拽绘图区(鼠标可能被捕获到绘图区外),或鼠标始终停留在绘图区内。 // 正在拖拽绘图区(鼠标可能被捕获到绘图区外),或鼠标始终停留在绘图区内。
//
// ⚠️ **必须同时看“当前正在拖拽哪个组件”**:拖拽信号面板的滚动条时,鼠标很容易
// 移动到绘图区上方,若只看 `inPlot && m_mouseOverPlot` 就会判定为“只影响绘图区”、
// 只失效绘图区 —— 面板的滚动位置明明已经变了,却一直不重绘(
// 用户报告的“拖住滚动条移到网格区域后滑块卡在原地不动”)。
const bool draggingNonPlot =
(m_activeTarget != Target::NONE) && (m_activeTarget != Target::PLOT) &&
(m_activeTarget != Target::EDIT_FIELD);
const bool inPlot = UiPointInRect(m_plotRect, static_cast<float>(x), static_cast<float>(y)); const bool inPlot = UiPointInRect(m_plotRect, static_cast<float>(x), static_cast<float>(y));
const bool plotOnlyMove = (m_activeTarget == Target::PLOT) || (inPlot && m_mouseOverPlot); const bool plotOnlyMove =
!draggingNonPlot && ((m_activeTarget == Target::PLOT) || (inPlot && m_mouseOverPlot));
if (!plotOnlyMove) if (!plotOnlyMove)
{ {
m_chromeDirty = true; m_chromeDirty = true;
@@ -2737,6 +2755,22 @@ namespace jdi
{ {
const int x = GET_X_LPARAM(lParam); const int x = GET_X_LPARAM(lParam);
const int y = GET_Y_LPARAM(lParam); const int y = GET_Y_LPARAM(lParam);
// ⚠️ **工具栏必须在这里也当作“按下”处理**:窗口类带 `CS_DBLCLKS`,快速连点同一个
// 按钮时,第二次(及之后的快速按下)会被 Windows 合成为 `WM_LBUTTONDBLCLK`。
// 工具栏没有双击语义,若这里不处理,那一次按下就完全丢失 ——
// 用户看到的正是“连点好几下、开关状态只切换了几次”。
if (m_toolbar.HitTest(x, y))
{
SetFocus(hwnd);
SetCapture(hwnd);
m_activeTarget = Target::TOOLBAR;
ToolbarAction action = ToolbarAction::NONE;
m_toolbar.OnLButtonDown(x, y, action);
InvalidateRect(hwnd, nullptr, FALSE);
return 0;
}
bool changed = false; bool changed = false;
if (m_tabBar.HitTest(x, y)) if (m_tabBar.HitTest(x, y))
{ {
+83 -2
View File
@@ -447,6 +447,52 @@ namespace jdi
*/ */
bool FitSinglePointSignal(); bool FitSinglePointSignal();
/**
* @brief 「网格内只显示单点信号」时的自适应(含多个网格各只有一个数据点)。
*
* 用户要求(2026-10-15):多网格、每个网格只有一个数据点时,适应视图必须保证
* **所有散点都在屏幕内**:
* * 时间轴左边界 = 所有点中最早的时刻 **再向左偏移一小格**(一小格 = 该视界下
* 主刻度 / 五等分,与网格叠底同源);右边界同理向右偏移一小格;
* * 若**所有点都在同一时刻**(最极端情况),按“首个网格加载为单点信号”的方式对齐
* (时间跨度 1s、居中);
* * 逐网格的 Y 轴:网格内若有**取值不同**的多个单点信号,纵向同样在各边界留出一小格
* (`LaneValueGridCell`);只有一个取值时跨度 1、居中。
*
* 任一网格里存在非单点的可见信号时**不适用**(返回 false,交回常规整段自适应)。
*
* @return 已按单点规则完成自适应返回 true。
*/
bool FitSinglePointLanes();
/**
* @brief 取某信号**唯一的有效数据点**(时刻 + 原始取值)。
*
* 优先用加载时缓存的稀疏点;没有缓存时逐个采样找(单点信号的采样数组必然很短)。
*
* @param [IN] signal 目标信号。
* @param [IN] axis 该信号使用的时间轴。
* @param [OUT] time 数据点时刻。 @param [OUT] value 数据点原始取值。
* @return 取到有效点返回 true。
*/
bool SinglePointOfSignal(const SignalData& signal, const TimeAxis& axis, double& time,
double& value) const;
/**
* @brief 时间轴**一小格**对应的时间跨度(= 主刻 / 五等分,与网格叠底同源)。
* @param [IN] span 参考跨度。
* @return 一小格的时间跨度(无法判定时返回 0)。
*/
double TimeGridCellSeconds(double span) const;
/**
* @brief 某网格**一小格**对应的幅值跨度(与网格叠底的横向刻度同一套算法)。
* @param [IN] span 参考幅值跨度。
* @param [IN] laneHeight 网格高度(像素,决定纵向刻度密度)。
* @return 一小格的幅值跨度(无法判定时返回 0)。
*/
double LaneValueGridCell(double span, float laneHeight) const;
/** /**
* @brief 以给定倍率缩放时间轴(以视图中心为锚点)。 * @brief 以给定倍率缩放时间轴(以视图中心为锚点)。
* @param [IN] factor 缩放倍率,大于 1 表示放大。 * @param [IN] factor 缩放倍率,大于 1 表示放大。
@@ -2483,21 +2529,44 @@ namespace jdi
* @brief 绘制某条信号的悬停/拖拽光晕(按缓存的折线模式选择最省的画法)。 * @brief 绘制某条信号的悬停/拖拽光晕(按缓存的折线模式选择最省的画法)。
* @param [IN,OUT] g 绘图上下文。 * @param [IN,OUT] g 绘图上下文。
* @param [IN] resolved 已解析的信号。 * @param [IN] resolved 已解析的信号。
* @param [IN] lane 该信号所在网格(单点信号需要它把数据点换算到屏幕坐标)。
* @param [IN] cached 该信号的折线缓存项(包络带模式下会走带外扩填充)。 * @param [IN] cached 该信号的折线缓存项(包络带模式下会走带外扩填充)。
*/ */
void PaintSignalHaloFor(gd::Graphics& g, const ResolvedSignal& resolved, void PaintSignalHaloFor(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane,
const PolylineCacheEntry& cached); const PolylineCacheEntry& cached);
/** /**
* @brief 绘制某条信号的悬停/拖拽光晕(折线描边版本)。 * @brief 绘制某条信号的悬停/拖拽光晕(折线描边版本)。
*
* ⚠️ 只有**单个有效数据点**的信号没有折线可描边(其默认样式是「点线」),
* 此时改为围绕那个数据点画一圈强调色圆环 —— 否则悬停时完全没有视觉反馈。
*
* @param [IN,OUT] g 绘图上下文。 * @param [IN,OUT] g 绘图上下文。
* @param [IN] resolved 已解析的信号。 * @param [IN] resolved 已解析的信号。
* @param [IN] lane 该信号所在网格。
* @param [IN] points 折线顶点。 * @param [IN] points 折线顶点。
* @param [IN] breaks 每段折线的起始下标。 * @param [IN] breaks 每段折线的起始下标。
*/ */
void PaintSignalHalo(gd::Graphics& g, const ResolvedSignal& resolved, void PaintSignalHalo(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane,
const std::vector<gd::PointF>& points, const std::vector<std::size_t>& breaks); const std::vector<gd::PointF>& points, const std::vector<std::size_t>& breaks);
/**
* @brief 单点信号那个数据点绘制时的半径(与散点绘制同源)。
* @param [IN] resolved 已解析的信号。
* @return 半径(像素)。
*/
float SinglePointDotRadius(const ResolvedSignal& resolved) const;
/**
* @brief 取某信号**唯一数据点**在屏幕上的位置(含时间偏移与「调整」变换)。
* @param [IN] resolved 已解析的信号(要求 `finiteCount == 1`)。
* @param [IN] lane 该信号所在网格。
* @param [OUT] out 屏幕坐标。
* @return 取到有效位置返回 true。
*/
bool SinglePointScreenPos(const ResolvedSignal& resolved, const LaneVisual& lane,
gd::PointF& out) const;
/** /**
* @brief 计算某网格图例中 GX 编号徽标的矩形(绘制与命中测试共用,保证一致)。 * @brief 计算某网格图例中 GX 编号徽标的矩形(绘制与命中测试共用,保证一致)。
* @param [IN] laneIndex 网格下标。 * @param [IN] laneIndex 网格下标。
@@ -2886,6 +2955,18 @@ namespace jdi
*/ */
double m_wideTickSpan = 0.0; double m_wideTickSpan = 0.0;
float m_wideTickHeight = 0.0f; float m_wideTickHeight = 0.0f;
/**
* @brief 渲染「信号图层(宽基底)」时使用的时间主刻步长(秒);0 = 按当前视界自行推算。
*
* ⚠️ **必须由 `RenderLaneLayerRegion` 显式传入(前台视界跨度算出的步长)**:
* 图层画布比前台宽约一倍(左右各半屏),而 `Tab().ViewMin/ViewMax` 在图层的临场渲染里
* 被改成了**宽窗口**区间 —— 若 `PaintLane` 直接拿这个区间去 `NiceStep`,算出的主刻步长会
* 比前台大(往往正好 2 倍),于是图层里的竖向网格线(含五等分辅助线)**落在与前台不同的位置**、
* 并且“与主刻重合则跳过”的判据也错位 → 用户看到网格线**错位/部分整条丢失**、
* 且在“拖拽帧(用图层)”与“结算帧(整幅重绘)”之间来回切换时**闪烁**。
* 与纵向的 `m_wideTickSpan/m_wideTickHeight` 是同一套思路(那两处已经做对了)。
*/
double m_renderTimeStep = 0.0;
/** /**
* @brief 本次 `PaintLane` 只绘制「装饰」(Y 轴刻度胶囊 + 图例 + 网格分界线)。 * @brief 本次 `PaintLane` 只绘制「装饰」(Y 轴刻度胶囊 + 图例 + 网格分界线)。
+315 -35
View File
@@ -1324,11 +1324,10 @@ namespace jdi
return; return;
} }
// 「仅有一个网格、且网格内只显示了一个**单点**信号」时,适应效果必须与 // 「网格内只显示单点信号」时的自适应(含“多个网格、每个网格只有一个数据点”):
// “首次加载并勾选该单点信号”时完全一致(数据点居中、时间跨度 1s、Y 跨度 1)。 // 必须保证所有数据点都在屏幕内(用户 2026-10-15 报告“多网格单点时自适应把点甩飞”)。
// 否则整段自适应会让时间轴覆盖整条时间列 —— 数据点被挤到网格边缘、看起来“飞了” // 该函数也覆盖“只有一个网格 + 单点信号”的情形(时间跨度 1s、居中,与原行为一致)。
//(用户 2026-10-15 报告)。 if (FitSinglePointLanes())
if (FitSinglePointSignal())
{ {
return; return;
} }
@@ -1420,29 +1419,9 @@ namespace jdi
return false; return false;
} }
// 取出那个唯一数据点的时刻与取值(加载时已缓存为稀疏点)。 double pointTime = 0.0;
double pointTime = std::numeric_limits<double>::quiet_NaN(); double pointValue = 0.0;
double pointValue = std::numeric_limits<double>::quiet_NaN(); if (!SinglePointOfSignal(*signal, *axis, pointTime, pointValue))
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; return false;
} }
@@ -1479,6 +1458,201 @@ namespace jdi
return true; return true;
} }
bool PlotView::SinglePointOfSignal(const SignalData& signal, const TimeAxis& axis, double& time,
double& value) const
{
time = std::numeric_limits<double>::quiet_NaN();
value = std::numeric_limits<double>::quiet_NaN();
// 正常路径:加载/插入信号时已把“有效点很少”的信号的全部有效点缓存下来。
if (!signal.sparseTimes.empty() && signal.sparseTimes.size() == signal.sparseValues.size())
{
time = signal.sparseTimes.front();
value = signal.sparseValues.front();
return std::isfinite(time) && std::isfinite(value);
}
// 兜底:逐个采样找那个有效点(单点信号的采样数组必然很短)。
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]))
{
time = axis.time[index];
value = signal.values[index];
return true;
}
}
return false;
}
double PlotView::TimeGridCellSeconds(double span) const
{
if (!(span > 0.0) || !std::isfinite(span))
{
return 0.0;
}
// 与网格绘制完全同源:主刻 = `NiceStep(跨度, kTimeTickTarget)`,一小格 = 主刻 / 五等分。
return NiceStep(span, kTimeTickTarget) / static_cast<double>(kMinorDivisions);
}
double PlotView::LaneValueGridCell(double span, float laneHeight) const
{
if (!(span > 0.0) || !std::isfinite(span) || laneHeight <= 1.0f || m_scale <= 0.0f)
{
return 0.0;
}
int tickTarget = static_cast<int>(std::lround(laneHeight / (kYTickSpacing * m_scale)));
tickTarget = std::max(kYTickTargetMin, std::min(tickTarget, kYTickTargetMax));
return NiceStep(span, tickTarget) / static_cast<double>(kMinorDivisions);
}
bool PlotView::FitSinglePointLanes()
{
if (m_tab == nullptr || m_dataSet == nullptr)
{
return false;
}
const std::vector<Lane>& lanes = m_tab->Lanes();
if (lanes.empty())
{
return false;
}
// 逐网格收集“可见信号”的唯一数据点;只要任何一个网格里还存在**非单点**的可见信号,
// 就不属于本函数的适用范围(交回常规的整段自适应)。
struct LanePoints
{
std::vector<double> values;
double minTime = std::numeric_limits<double>::infinity();
double maxTime = -std::numeric_limits<double>::infinity();
};
std::vector<LanePoints> perLane(lanes.size());
bool anyPoint = false;
for (std::size_t laneIndex = 0; laneIndex < lanes.size(); ++laneIndex)
{
for (const SignalRef& ref : lanes[laneIndex].signals)
{
if (!m_tab->IsVisible(ref))
{
continue;
}
const SignalData* data = m_dataSet->Resolve(ref);
const TimeAxis* axis = m_dataSet->Axis(ref);
if (data == nullptr || axis == nullptr)
{
continue;
}
if (data->finiteCount != 1)
{
return false; // 该网格里还有常规信号
}
double pointTime = 0.0;
double pointValue = 0.0;
if (!SinglePointOfSignal(*data, *axis, pointTime, pointValue))
{
continue; // 没有有效点(正常数据不会出现):跳过
}
LanePoints& points = perLane[laneIndex];
points.values.push_back(pointValue);
points.minTime = std::min(points.minTime, pointTime);
points.maxTime = std::max(points.maxTime, pointTime);
anyPoint = true;
}
}
if (!anyPoint)
{
return false;
}
// 全局时间范围(“最左边/最右边的那个数据点”)。
double minTime = std::numeric_limits<double>::infinity();
double maxTime = -std::numeric_limits<double>::infinity();
for (const LanePoints& points : perLane)
{
if (points.values.empty())
{
continue;
}
minTime = std::min(minTime, points.minTime);
maxTime = std::max(maxTime, points.maxTime);
}
// 时间轴:左右各留出**一小格**(一小格 = 主刻 / 五等分,与网格叠底同源)。
// ⚠️ 留白会轻微改变“一小格”的值,因此迭代两遍让最终区间与最终网格自洽。
if (maxTime > minTime)
{
const double dataSpan = maxTime - minTime;
double cell = TimeGridCellSeconds(dataSpan);
for (int pass = 0; pass < 2; ++pass)
{
cell = TimeGridCellSeconds(dataSpan + 2.0 * cell);
}
SetTimeView(minTime - cell, maxTime + cell);
}
else
{
// 最极端情况:**所有数据点都在同一时刻** ⇒ 按“首个网格加载为单点信号”的方式对齐
//(时间跨度 1s、数据点居中)。
SetTimeView(minTime - 0.5, minTime + 0.5);
}
Tab().ViewInitialized() = true;
Tab().LaneScroll() = 0.0f;
Tab().ZoomMode() = PlotZoomMode::NONE;
m_rubberRect = gd::RectF();
m_dragMode = PlotDragMode::NONE;
m_dragSignal = SignalRef();
m_dragTargetLane = -1;
m_hoverSignal = SignalRef();
m_rubberAxis = PlotRubberAxis::UNDECIDED;
SetHint(std::wstring());
// 逐网格的 Y 轴:
// * 网格内有**多个取值不同**的单点信号 ⇒ 纵向也留出“一小格”的余量(与横向同规则);
// * 只有一个取值(含只有一条信号) ⇒ 跨度 1、数据点居中(与单点初始视图一致)。
for (std::size_t laneIndex = 0; laneIndex < lanes.size() && laneIndex < m_lanes.size(); ++laneIndex)
{
const LanePoints& points = perLane[laneIndex];
if (points.values.empty())
{
continue; // 该网格没有可见单点信号:保持原样
}
const double valueMin = *std::min_element(points.values.begin(), points.values.end());
const double valueMax = *std::max_element(points.values.begin(), points.values.end());
Lane& lane = m_tab->Lanes()[laneIndex];
lane.autoRange = false;
lane.fitYToSelection = false;
if (valueMax > valueMin)
{
const double valueSpan = valueMax - valueMin;
const float laneHeight = m_lanes[laneIndex].rect.Height;
double cell = LaneValueGridCell(valueSpan, laneHeight);
for (int pass = 0; pass < 2; ++pass)
{
cell = LaneValueGridCell(valueSpan + 2.0 * cell, laneHeight);
}
if (!(cell > 0.0))
{
cell = valueSpan * kLaneRangePadRatio;
}
lane.yMin = valueMin - cell;
lane.yMax = valueMax + cell;
}
else
{
lane.yMin = valueMax - 0.5;
lane.yMax = valueMax + 0.5;
}
}
InvalidateSceneCache();
BuildLaneVisuals();
return true;
}
void PlotView::DisableCursor() void PlotView::DisableCursor()
{ {
Tab().CursorMode() = PlotCursorMode::NONE; Tab().CursorMode() = PlotCursorMode::NONE;
@@ -4609,6 +4783,49 @@ namespace jdi
return true; return true;
} }
float PlotView::SinglePointDotRadius(const ResolvedSignal& resolved) const
{
if (resolved.style == nullptr)
{
return kMinScatterRadius * m_scale;
}
// 与 `PaintLane` / 拖拽虚影画那个点时的半径**完全同源**(散点半径随线宽线性增长),
// 命中判定与光晕半径都复用它,三者不会出现“看着点中了却没反应”。
const double radius = ClampDouble(static_cast<double>(resolved.style->lineWidth) * kScatterRadiusScale,
static_cast<double>(kMinScatterRadius),
static_cast<double>(kMaxScatterRadius));
return static_cast<float>(radius) * m_scale;
}
bool PlotView::SinglePointScreenPos(const ResolvedSignal& resolved, const LaneVisual& lane,
gd::PointF& out) const
{
if (resolved.data == nullptr || resolved.axis == nullptr || resolved.style == nullptr)
{
return false;
}
if (resolved.data->finiteCount != 1)
{
return false;
}
double time = 0.0;
double value = 0.0;
if (!SinglePointOfSignal(*resolved.data, *resolved.axis, time, value))
{
return false;
}
// 与绘制一致:采样画在 `t + offsetTime` 处,取值先过「调整」变换。
const double displayTime = time + resolved.style->offsetTime;
const double drawValue = ApplyDrawTransform(*resolved.style, value);
if (!std::isfinite(displayTime) || !std::isfinite(drawValue))
{
return false;
}
out.X = TimeToX(displayTime);
out.Y = ValueToY(drawValue, lane);
return true;
}
SignalRef PlotView::HitTestSignal(int x, int y) const SignalRef PlotView::HitTestSignal(int x, int y) const
{ {
SignalRef empty; SignalRef empty;
@@ -4644,6 +4861,30 @@ namespace jdi
continue; continue;
} }
// ⚠️ **只有单个有效数据点**的信号没有“折线”可测距(`AcquirePolyline` 给不出两点的
// 线段),必须直接用数据点的屏幕坐标判定。否则用户永远无法悬停/拖动单点信号
//(用户 2026-10-15 报告:“光标瞄到数据点没有高亮、也不能拖拽换网格”)。
if (resolved.data->finiteCount == 1)
{
gd::PointF dot;
if (!SinglePointScreenPos(resolved, lane, dot))
{
continue;
}
const float dx = dot.X - fx;
const float dy = dot.Y - fy;
const float distance = std::sqrt(dx * dx + dy * dy);
// 容差取“常规命中容差”与“该点绘制半径”的较大者:点本身很粗时更好点中。
const float dotRadius = SinglePointDotRadius(resolved);
const float limit = std::max(tolerance, dotRadius);
if (distance <= limit && distance <= bestDistance + dotRadius)
{
bestDistance = distance;
best = ref;
}
continue;
}
// 复用绘制阶段的折线顶点:悬停检测通常与上一帧几何完全相同, // 复用绘制阶段的折线顶点:悬停检测通常与上一帧几何完全相同,
// 不再为“鼠标移动但命中结果未变”重复扫描全量采样点。 // 不再为“鼠标移动但命中结果未变”重复扫描全量采样点。
const PolylineCacheEntry* cached = AcquirePolyline(ref, resolved, lane); const PolylineCacheEntry* cached = AcquirePolyline(ref, resolved, lane);
@@ -7442,7 +7683,7 @@ namespace jdi
paintOverlays(); paintOverlays();
} }
void PlotView::PaintSignalHaloFor(gd::Graphics& g, const ResolvedSignal& resolved, void PlotView::PaintSignalHaloFor(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane,
const PolylineCacheEntry& cached) const PolylineCacheEntry& cached)
{ {
if (resolved.style == nullptr) if (resolved.style == nullptr)
@@ -7451,13 +7692,28 @@ namespace jdi
} }
// 光晕一律用折线描边(与"之前"的画法一致)。曾经在包络带模式下改成"带外扩填充", // 光晕一律用折线描边(与"之前"的画法一致)。曾经在包络带模式下改成"带外扩填充",
// 虽然更快但观感变了(用户反馈显示效果下降),因此恢复。 // 虽然更快但观感变了(用户反馈显示效果下降),因此恢复。
PaintSignalHalo(g, resolved, cached.points, cached.breaks); PaintSignalHalo(g, resolved, lane, cached.points, cached.breaks);
} }
void PlotView::PaintSignalHalo(gd::Graphics& g, const ResolvedSignal& resolved, void PlotView::PaintSignalHalo(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane,
const std::vector<gd::PointF>& points, const std::vector<gd::PointF>& points,
const std::vector<std::size_t>& breaks) const std::vector<std::size_t>& breaks)
{ {
// ⚠️ **只有单个数据点**的信号(默认样式是「点线」,那个点本身是一个粗圆点):
// 折线光晕无从下手(不足两个顶点),改为在数据点外包一圈强调色圆环。
// 否则悬停时完全没有“可拖拽”的视觉反馈(用户 2026-10-15 报告)。
if (resolved.data != nullptr && resolved.data->finiteCount == 1)
{
gd::PointF dot;
if (SinglePointScreenPos(resolved, lane, dot))
{
const float radius = SinglePointDotRadius(resolved) + 3.0f * m_scale;
gd::Pen ring(ToGdiColor(WithAlpha(m_theme->accent, 0x88)), 2.0f * m_scale);
g.DrawEllipse(&ring, dot.X - radius, dot.Y - radius, radius * 2.0f, radius * 2.0f);
}
return;
}
const bool drawLine = (resolved.style->mode != DrawMode::SCATTER); const bool drawLine = (resolved.style->mode != DrawMode::SCATTER);
if (!drawLine || breaks.empty()) if (!drawLine || breaks.empty())
{ {
@@ -7521,7 +7777,7 @@ namespace jdi
const PolylineCacheEntry* cached = AcquirePolyline(m_hoverSignal, resolved, lane); const PolylineCacheEntry* cached = AcquirePolyline(m_hoverSignal, resolved, lane);
if (cached != nullptr) if (cached != nullptr)
{ {
PaintSignalHaloFor(g, resolved, *cached); PaintSignalHaloFor(g, resolved, lane, *cached);
} }
} }
} }
@@ -7594,7 +7850,7 @@ namespace jdi
const LaneVisual& lane = m_lanes[static_cast<std::size_t>(laneIndex)]; const LaneVisual& lane = m_lanes[static_cast<std::size_t>(laneIndex)];
if (const PolylineCacheEntry* cached = AcquirePolyline(m_hoverLegend, resolved, lane)) if (const PolylineCacheEntry* cached = AcquirePolyline(m_hoverLegend, resolved, lane))
{ {
PaintSignalHaloFor(g, resolved, *cached); PaintSignalHaloFor(g, resolved, lane, *cached);
} }
} }
@@ -9246,6 +9502,7 @@ namespace jdi
const float savedScroll = Tab().LaneScroll(); const float savedScroll = Tab().LaneScroll();
const double savedWideTickSpan = m_wideTickSpan; const double savedWideTickSpan = m_wideTickSpan;
const float savedWideTickHeight = m_wideTickHeight; const float savedWideTickHeight = m_wideTickHeight;
const double savedRenderTimeStep = m_renderTimeStep;
m_plotRect = gd::RectF(0.0f, 0.0f, static_cast<float>(bitmapWidth), m_plotRect = gd::RectF(0.0f, 0.0f, static_cast<float>(bitmapWidth),
static_cast<float>(bitmapHeight)); static_cast<float>(bitmapHeight));
@@ -9313,16 +9570,21 @@ namespace jdi
UiFillRect(g, m_area, m_theme->cardBg); UiFillRect(g, m_area, m_theme->cardBg);
// 竖向网格刻度沿用**前台视界跨度**(而非宽窗口的跨度),否则竖网格线会只有一半。 // 竖向网格刻度沿用**前台视界跨度**(而非宽窗口的跨度),否则竖网格线会只有一半。
// ⚠️ 同时把它作为竖向网格的**主刻步长**传给 `PaintLane`(见 `m_renderTimeStep`):
// 不传就会让图层用宽窗口跨度重算步长 → 竖向网格线与前台错位、部分整条丢失。
const double fgSpan = savedViewMax - savedViewMin; const double fgSpan = savedViewMax - savedViewMin;
const double timeStep = NiceStep((fgSpan > 0.0) ? fgSpan : 1.0, kTimeTickTarget); const double timeStep = NiceStep((fgSpan > 0.0) ? fgSpan : 1.0, kTimeTickTarget);
m_renderTimeStep = timeStep;
std::vector<double> timeTicks; std::vector<double> timeTicks;
BuildTicks(Tab().ViewMin(), Tab().ViewMax(), timeStep, timeTicks); BuildTicks(Tab().ViewMin(), Tab().ViewMax(), timeStep, timeTicks);
PaintLane(g, laneIndex, m_lanes[static_cast<std::size_t>(laneIndex)], timeTicks); PaintLane(g, laneIndex, m_lanes[static_cast<std::size_t>(laneIndex)], timeTicks);
m_renderTimeStep = 0.0;
} }
m_fastFrame = savedFastFrame; m_fastFrame = savedFastFrame;
m_renderWide = savedRenderWide; m_renderWide = savedRenderWide;
m_wideTickSpan = savedWideTickSpan; m_wideTickSpan = savedWideTickSpan;
m_wideTickHeight = savedWideTickHeight; m_wideTickHeight = savedWideTickHeight;
m_renderTimeStep = savedRenderTimeStep;
m_stripRenderActive = savedStripActive; m_stripRenderActive = savedStripActive;
m_stripX0 = savedStripX0; m_stripX0 = savedStripX0;
m_stripX1 = savedStripX1; m_stripX1 = savedStripX1;
@@ -9978,8 +10240,13 @@ namespace jdi
} }
// 时间网格线(同样补充五等分估读线)。 // 时间网格线(同样补充五等分估读线)。
// ⚠️ 主刻步长优先取 `m_renderTimeStep`(渲染信号图层时由前台跨度给出,见该成员的说明):
// 图层渲染的 `Tab().ViewMin/ViewMax` 是**宽窗口**区间,直接拿它算 `NiceStep` 会得到比前台
// 更大的步长(往往正好 2 倍),使图层里的竖向网格线与前台错位、部分整条丢失。
const double timeSpan = Tab().ViewMax() - Tab().ViewMin(); const double timeSpan = Tab().ViewMax() - Tab().ViewMin();
const double timeMajorStep = NiceStep((timeSpan > 0.0) ? timeSpan : 1.0, kTimeTickTarget); const double timeMajorStep =
(m_renderTimeStep > 0.0) ? m_renderTimeStep
: NiceStep((timeSpan > 0.0) ? timeSpan : 1.0, kTimeTickTarget);
const double timeMinorStep = timeMajorStep / static_cast<double>(kMinorDivisions); const double timeMinorStep = timeMajorStep / static_cast<double>(kMinorDivisions);
std::vector<double> timeMinorTicks; std::vector<double> timeMinorTicks;
BuildTicks(Tab().ViewMin(), Tab().ViewMax(), timeMinorStep, timeMinorTicks); BuildTicks(Tab().ViewMin(), Tab().ViewMax(), timeMinorStep, timeMinorTicks);
@@ -10074,7 +10341,7 @@ namespace jdi
const bool highlighted = (ref == haloRef); const bool highlighted = (ref == haloRef);
if (highlighted) if (highlighted)
{ {
PaintSignalHaloFor(g, resolved, *cached); PaintSignalHaloFor(g, resolved, lane, *cached);
} }
if (drawLine && !breaks.empty()) if (drawLine && !breaks.empty())
@@ -13172,6 +13439,19 @@ namespace jdi
g.FillPath(&brush, &ghostPath); g.FillPath(&brush, &ghostPath);
} }
} }
else if (drawScatter && resolved.data->finiteCount == 1)
{
// ⚠️ **只有单个有效数据点**的信号:折线缓存给不出顶点(`points` 可能为空),
// 因此直接按数据点的屏幕坐标画那个圆点,否则拖动时虚影会**整个消失**
//(用户 2026-10-15 要求单点信号也能拖拽换网格)。
gd::PointF dot;
if (SinglePointScreenPos(resolved, sourceVisual, dot))
{
const float radius = SinglePointDotRadius(resolved);
gd::SolidBrush brush(ToGdiColor(ghostColor));
g.FillEllipse(&brush, dot.X - radius, dot.Y - radius, radius * 2.0f, radius * 2.0f);
}
}
g.Restore(saveState); g.Restore(saveState);
} }
@@ -2336,6 +2336,9 @@ namespace jdi
if (m_draggingScrollBar) if (m_draggingScrollBar)
{ {
const gd::RectF thumb = ScrollThumbRect(); const gd::RectF thumb = ScrollThumbRect();
// ⚠️ 拖拽期间滑块高度不会变(内容高度与列表高度都没变),因此 `thumb.Height` 可安全
// 用于本次换算;但 `m_scrollBarRect.Y` 必须取**按下时**记录的值(局部刷新/重排可能
// 改掉它)。坐标一律用**客户区** y,因此鼠标移到面板外(如绘图区)也能继续跟随。
const float travel = static_cast<float>(y - m_scrollGrabOffset) - m_scrollBarRect.Y; const float travel = static_cast<float>(y - m_scrollGrabOffset) - m_scrollBarRect.Y;
const float usable = m_scrollBarRect.Height - thumb.Height; const float usable = m_scrollBarRect.Height - thumb.Height;
if (usable > 0.0f) if (usable > 0.0f)
+103
View File
@@ -1707,6 +1707,88 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸(
--- ---
### 9.32 信号面板滚动条跟随 / 工具栏连点丢状态 / 图层网格线步长 / 多网格单点自适应 / 单点信号拖拽(本轮,起源设备)
> 本轮五项(用户 2026-10-15 第四轮)。离屏验证 `.preflight/check` **67/67 通过**(连跑 3 次稳定);
> **A/B** 回退六处修复后 **17 项如实失败**(含 [C] 的"辅助线 49px vs 前台 25px / 20 vs 40 条 / 最大偏移 539px")。
#### 一、拖动信号面板滚动条、鼠标移到绘图区后"滑块卡在原地不动"
- **根因**:`Application::WM_MOUSEMOVE` 里
`plotOnlyMove = (m_activeTarget == Target::PLOT) || (inPlot && m_mouseOverPlot)`。
拖滚动条时鼠标很容易进入绘图区 ⇒ `inPlot && m_mouseOverPlot` 为真 ⇒ 判定"本帧只影响绘图区"
⇒ 只 `InvalidatePlotRect`;而面板的滚动偏移**已经变了**却没有重绘 ⇒ 滑块看着不动(滚动值其实在变)。
- **修法**:`draggingNonPlot = (m_activeTarget != NONE) && != PLOT && != EDIT_FIELD`,
`plotOnlyMove` 必须先排除它(正在拖拽非绘图区组件时,一律走完整失效)。
- 验证 `[A]`:真实按下滚动条 + 移入绘图区移动两次 ⇒ 滚动偏移 `0 → 2088.7 → 1766.1`、`m_chromeDirty` 为真、松手结束拖拽。
#### 二、工具栏快速连点 / 双击丢状态
- **两个根因**:
(a) 窗口类带 `CS_DBLCLKS`,快速第二次按下被 Windows 合成 `WM_LBUTTONDBLCLK`,而工具栏没处理它
⇒ **那一次按下完全丢失**("连点好几下,开关只切换了几次");
(b) `HandleMouseMove` 只要鼠标不在工具栏上就调 `m_toolbar.OnMouseLeave()` ⇒ 清掉 `m_pressedIndex`
⇒ 随后在按钮上松手什么也不发生(按住期间从按钮边缘划出即命中这条)。
- **修法**:`WM_LBUTTONDBLCLK` 命中工具栏时按"按下"处理(`SetFocus`/`SetCapture`/`OnLButtonDown`);
`m_activeTarget == Target::TOOLBAR` 期间**不调** `OnMouseLeave()`。
- 验证 `[B]`:三次快速点击(第 2/3 次走 `WM_LBUTTONDBLCLK`)⇒ `NONE → SINGLE → DOUBLE → NONE`;
按住 → 移出一屏 → 回到按钮松手仍能切换。
#### 三、网格叠底:拖拽期间竖向网格线错位/丢失 + 操作信号样式时"闪烁"
- **根因**:`PaintLane` 的竖向网格**主刻步长**由 `Tab().ViewMax() - Tab().ViewMin()` 推出,
而渲染信号图层时 `RenderLaneLayerRegion` 把该区间改成了**宽窗口**(≈2× 前台)
⇒ 图层里的辅助线步长 2×、位置与前台不同,且"与主刻重合则跳过"的判据也随之错位
⇒ 贴图后网格线**错位 / 整条丢失**;在"拖拽帧(图层像素)"与"结算帧(整幅重绘)"之间来回切换时就是用户看到的**闪烁**。
- **修法**:新增 `PlotView::m_renderTimeStep`,由 `RenderLaneLayerRegion` 用**前台视界跨度**算好传入
(`PaintLane` 优先用它;与纵向已做对的 `m_wideTickSpan/m_wideTickHeight` 同一思路),渲染完恢复原值。
- 验证 `[C]`:① 直接检查**图层位图自己的像素** —— 图层主刻间距 123px vs 前台 122px、**辅助线间距 25px vs 25px**;
② 子窗口贴图帧与同视界整幅重绘帧的竖线数量一致(**10 主 / 40 辅**)且逐列偏移 ≤1px;
③ 断言贴图帧确实来自宽基底路径(`m_wideLastValid`)。
A/B(回退)如实报:辅助线 **49px vs 25px**、**20 vs 40 条**、最大列偏移 **539px**。
#### 四、多个网格、每个网格只有一个数据点时「适应视图」
用户给出的规则(逐条落地在 `PlotView::FitSinglePointLanes()`):
* **时间轴**:左边界 = 所有网格中**最左**的数据点时间,再向左偏**一个网格小格**对应的时间跨度
(`TimeGridCellSeconds()`,与网格绘制同源);右边界 = 最右数据点时间 + 一个小格。
* **Y 轴(逐网格)**:按该网格自己的数据点范围自适应;若网格内存在**时间一致但 Y 值不一致**的多个单点
⇒ 纵向上下边界也各留**一个 Y 小格**的高度(`LaneValueGridCell()`,与网格绘制同源)。
* **最极端情况**(网格中所有数据点都在**同一个时间位置**):每个网格的适应按
"首个网格加载且加载为单数据点信号时"的时间轴对齐方式 —— **跨度 1s、以该时刻居中**。
* 某网格只有一个取值 ⇒ 该网格 Y 跨度 **1** 且居中于该值。
* ⚠️ 规则只在**所有可见信号都是单点**(`finiteCount == 1`)时启用,否则交回常规 `FitAll`。
验证 `[D]`:MERGED 后把 b0 移到新网格(网格0 = {a0=5.0, a1=−3.0}、网格1 = {b0=−2.0})⇒
时间视界 `[0.000, 3.800]`(数据 `[0.1, 3.7]` ± 一小格 0.1)、网格0 区间覆盖 4.5~5.5、
网格1 = `[−2.5, −1.5]`(跨度 1 居中)、同刻退化用例跨度 1 居中于 0.1;并含"有常规信号时规则不生效"的反向断言。
#### 五、单点信号的高亮与拖拽换网格
- `HitTestSignal` 新增**单点分支**(`SinglePointScreenPos()` + 容差 = `max(常规命中容差, 该点绘制半径)`)——
单点信号没有折线可测距,不单独处理就永远悬停不到。
- 悬停:光晕换成**圆环**(复用 `PaintSignalHalo`,单点用环)且**不改鼠标形状**
(`QueryCursor` 不查 `HitTestSignal`);按下后进入常规信号拖拽(`IDC_SIZEALL`、虚影为实心点 + 环)。
- 验证 `[E]`:命中判定、`QueryCursor == nullptr`、光晕 57600 像素、从网格1 拖到网格0 成功。
#### 六、本轮验证方法学(重要教训)
- 离屏 `.preflight/check`:`SW_HIDE` 窗口 + 只注入合成消息 + 进程/线程 `BELOW_NORMAL`;**67/67 通过**。
- ⚠️ **A/B 必须让被测代码真的参与编译**:该 harness 链接的是 `Build/Release/*.o`,
只改源文件不 `make release` ⇒ 测的仍是旧目标文件(实测"回退后依然全绿"的假结论);
`Copy-Item` 还会**保留时间戳**,使 `make` 认为 `.o` 最新(实测"恢复后直接 make 不重编译")
⇒ **回退/恢复后都要把时间戳推到当前,再 `make release`**,最后用临时输出名验证链接
(`make release TARGET_FILE_NAME=Target_check`,因为用户正在运行 `Target.exe` 时它被锁定)。
- ⚠️ **判别力要靠"量"而不是"看起来对"**:本轮的 `[C]` 一开始写的是"两帧逐列比较",
但在图层没被真正使用时两帧**恒等** ⇒ 毫无判别力;直到
(a) 断言帧确实来自宽基底贴图(`m_wideLastValid`)、(b) 直接检查**图层位图自身的网格线间距**,才真正抓住缺陷。
- ⚠️ 网格线检测细节:GDI 写 DIB **不设置 alpha 字节**(比较必须按 24 位掩码);
"竖线"的列阈值**必须 ≥ 半个捕获高度**(横向网格线会让每一列都带上几十像素,
用小阈值会把 1212/1225 列全判成竖线 → 位置比较彻底失真)。
---
## 10. 变更日志(本文件维护记录) ## 10. 变更日志(本文件维护记录)
| 日期 | 设备/模型 | 摘要 | | 日期 | 设备/模型 | 摘要 |
@@ -1751,6 +1833,7 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸(
| 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(**起源设备**) | **全局 Y 轴自适应跟随网格结构 + 组名悬浮 + 稀疏信号默认点线 + 单点光标吸附 + 放大自动点线(详见 §9.29)**。① **新网格不继承全局 Y 轴自适应**:`RebuildLanes` 对新出现的信号返回默认 `LaneCarry()` ⇒ `fitYToSelection=false`;修法:重建前先算 `previousAllAutoFit`(非空且**每个**网格都开着 = 全局开启的正式定义),`LaneCarry` 加 `matched` 标记,新信号用它当默认;并新增 `ReapplyGlobalAutoFitIfEnabled()` 在 **`Layout()`(网格数变化)/`RefreshLanes()`(信号数变化)/`SplitLane()`** 之后立即重算 ⇒ 新勾选(按**当前视界**自适应)、取消显示(剩余信号重新收敛)、拆分(每份各自自适应)三项全覆盖。② **组名悬浮**:新增 `SignalPanel::StickyGroupRowIndex()` + `PaintStickyGroupRow()`(不透明底 + 底部边界线),`RowRect` 加 `m_rowYOverrideActive/Y` 覆盖行顶部(行内所有几何的唯一来源);判据 = 分组行已在列表上边界之上且本组最后一行仍在列表内;常规循环**跳过**被钉住的行(否则画两次)。③ **稀疏信号默认样式**:新增 `ApplySparseDefaultStyle`(1 个有效点 → 点线 + **最大线宽 10px**;2..16 → 点线 + 2px),在 `BuildSignalGroup`/`InsertDerivedSignal` 两处调用;线宽常量统一到 `DataModel.hpp`(`kSignalLineWidthMin/Max`)并被 SignalPanel 滑块引用;`ComputeStats` 输出 `firstFiniteIndex`、`SignalData` 新增 `singlePointTime`。④ **单点光标吸附**:`SnapTimeToSample` 新增半径分支(`kCursorSnapPointRadiusPixels=8` 逻辑像素)吸附 `singlePointTime`,半径外自由移动(**吸附而非卡住**)。⑤ **放大自动点线**:`SignalStyle` 新增 `autoScatterActive/autoScatterSavedMode/autoScatterUserOverride`,`AxisSparseInView`(间距 > 半个绘图区宽度,与吸附上门限同源,**单点轴不算稀疏**)+ `UpdateAutoScatterStyles()`(在 `SetTimeView` 等四处调用)⇒ 进入阈值自动切点线、退出阈值恢复原样式,**用户手动改过则不恢复**(判定必须放在恢复判断**之前**,否则用户改完紧接着缩出去会被覆盖)。验证:新增离屏 `.preflight/check` **65/65 通过**(连跑 3 次稳定,覆盖 7 组断言;含“钉住行与自然位置逐像素一致”“单点吸附半径内外”“用户手动改样式后退出不覆盖”),**A/B**:临时禁用六个机制后 **21 项如实失败**;`make release` 零警告。 |
| 2026-10-15 | DeepSeek-V4.1-Flash(**起源设备**) | **组名平滑悬浮 + 单点信号初始视图 + 光标吸附统一判定(自动点线同源)(详见 §9.30)**。① **组名悬浮平滑化**:位置改为 `StickyGroupRowTop = min(列表上边界, 本组最后一行下边界 − 组名高度)` ⇒ 组名随最后一行**自然滚出**(不再突然消失),且下边界永不越过下一个分组行的自然位置 ⇒ **不再遮住下一组名**(旧实现固定钉在列表上边界会把下一组名盖住,让别人以为后面的信号属于上一组);**折叠的分组不悬浮**(随滚动自然划过);底部边界线只在真的“浮”起来时才画。② **单点信号初始视图**:新增 `PlotView::FitSinglePointSignal()`(无任何信号被勾选、勾选的第一个信号只有 1 个有效点时生效)—— 数据点居中、**时间跨度 1s**、**Y 跨度 1**,并置 `autoRange=false`/`fitYToSelection=false`;`Application::SynchronizeView` 首次勾选时先试它、失败才 `FitAll()`;`AutoRangeLaneToView` 的退化区间改为 `PadRange` 同款留白 `max(1,|v|×0.1)`。③ **吸附判定统一**(用户给出的唯一判据):新增 `GridMinorCellCount()`(网格一横排小格数 N,与网格绘制同源)+ `SignalSnapEnabled()`(**当且仅当**某信号在当前一屏内的数据点 `< N×4` 才开启吸附,随视界实时重算 ⇒ 疏密信号自适应),`SnapTimeToSample` **只按鼠标所在网格**判定、同网格内所有符合判定的信号数据点都参与、吸附半径 = 一个网格小格宽且不超过半个局部采样间距(吸附≠卡住);旧的 `SampleSpacingPixels`(6px 下门限+半屏宽上门限)**已删除**。④ **自动点线改为与吸附判定完全同源**(`UpdateAutoScatterStyles` 调 `SignalSnapEnabled`),旧 `AxisSparseInView` **已删除**;进入阈值自动切点线、不再满足自动恢复、用户手动改过则不恢复。⑤ **需求作废**:“2…16 个有效采样点默认点线 + 2px”**已取消**(只保留“1 个有效点 → 点线 + 最大线宽”);`SignalData::singlePointTime` 升级为 **`sparseTimes`/`sparseValues`**(有效点 ≤ 1024 时全部缓存,供吸附与单点视图使用)。验证:新增离屏 `.preflight/check` **55/55 通过**(连跑 3 次稳定;覆盖组名悬浮四种场景、单点初始视图、吸附判定/网格作用域/多信号/动态切换/单点半径、自动点线同源与用户覆盖);**A/B** 禁用三项机制 ⇒ **14 项如实失败**;`make release` 零警告。 | | 2026-10-15 | DeepSeek-V4.1-Flash(**起源设备**) | **组名平滑悬浮 + 单点信号初始视图 + 光标吸附统一判定(自动点线同源)(详见 §9.30)**。① **组名悬浮平滑化**:位置改为 `StickyGroupRowTop = min(列表上边界, 本组最后一行下边界 − 组名高度)` ⇒ 组名随最后一行**自然滚出**(不再突然消失),且下边界永不越过下一个分组行的自然位置 ⇒ **不再遮住下一组名**(旧实现固定钉在列表上边界会把下一组名盖住,让别人以为后面的信号属于上一组);**折叠的分组不悬浮**(随滚动自然划过);底部边界线只在真的“浮”起来时才画。② **单点信号初始视图**:新增 `PlotView::FitSinglePointSignal()`(无任何信号被勾选、勾选的第一个信号只有 1 个有效点时生效)—— 数据点居中、**时间跨度 1s**、**Y 跨度 1**,并置 `autoRange=false`/`fitYToSelection=false`;`Application::SynchronizeView` 首次勾选时先试它、失败才 `FitAll()`;`AutoRangeLaneToView` 的退化区间改为 `PadRange` 同款留白 `max(1,|v|×0.1)`。③ **吸附判定统一**(用户给出的唯一判据):新增 `GridMinorCellCount()`(网格一横排小格数 N,与网格绘制同源)+ `SignalSnapEnabled()`(**当且仅当**某信号在当前一屏内的数据点 `< N×4` 才开启吸附,随视界实时重算 ⇒ 疏密信号自适应),`SnapTimeToSample` **只按鼠标所在网格**判定、同网格内所有符合判定的信号数据点都参与、吸附半径 = 一个网格小格宽且不超过半个局部采样间距(吸附≠卡住);旧的 `SampleSpacingPixels`(6px 下门限+半屏宽上门限)**已删除**。④ **自动点线改为与吸附判定完全同源**(`UpdateAutoScatterStyles` 调 `SignalSnapEnabled`),旧 `AxisSparseInView` **已删除**;进入阈值自动切点线、不再满足自动恢复、用户手动改过则不恢复。⑤ **需求作废**:“2…16 个有效采样点默认点线 + 2px”**已取消**(只保留“1 个有效点 → 点线 + 最大线宽”);`SignalData::singlePointTime` 升级为 **`sparseTimes`/`sparseValues`**(有效点 ≤ 1024 时全部缓存,供吸附与单点视图使用)。验证:新增离屏 `.preflight/check` **55/55 通过**(连跑 3 次稳定;覆盖组名悬浮四种场景、单点初始视图、吸附判定/网格作用域/多信号/动态切换/单点半径、自动点线同源与用户覆盖);**A/B** 禁用三项机制 ⇒ **14 项如实失败**;`make release` 零警告。 |
| 2026-10-15 | DeepSeek-V4.1-Flash(**起源设备**) | **自适应范围/时机澄清 + 适应视图与 Y 轴自适应互不干扰 + 吸附跟随时间偏移(详见 §9.31)**。① **澄清“自适应范围/时机”**(修正记忆里“额外要求视界内至少有一个可见数据点”这句不完善的表述):**首个网格**显示内容时 **Y 轴与时间轴都**自适应;只有一个数据点的信号要**居中显示在网格里**(`FitSinglePointSignal`:居中 + 时间跨度 1s + Y 跨度 1);**后续新增网格只做 Y 轴自适应、不再调整时间轴**。② **「适应视图」与「Y 轴自适应」互相干扰**:根因是两条自适应路径**留白不同**(`PadRange` 6% vs `AutoRangeLaneToView` 5%)⇒ 视界未变时也会算出不同区间、网格被无意义重绘(实测切一次 **143513** 像素、反向 **137326** 像素)。修法:新增 `DataModel.hpp` 的 **`PadValueRange()`** 作为唯一留白实现(含退化区间 `max(1,|v|×0.1)`),`PadRange` 与 `AutoRangeLaneToView` 共用 ⇒ 四个步骤(适应视图→开自适应→关自适应→再适应视图)**区间逐位相同且绘图区逐像素 0 差异**。③ **「适应视图」在单点信号上让数据点“飞了”**:`FitAll()` 开头先试 `FitSinglePointSignal()`(仅一个网格 + 唯一可见信号为单点),与首次加载效果一致且幂等。④ **吸附跟随「调整 → 时间轴偏移」**:采样点画在 `time[k] + offsetTime` 处而光标存的是显示时间,旧实现按源时刻吸附 ⇒ 落到历史位置;修法:`SignalSnapEnabled`/`HasVisibleSample`/`NearestSnapSample`/`LocalSampleSpacing` 全部加 `timeShift`,查找时 `time - timeShift` 回源空间、返回时加回去,`UpdateAutoScatterStyles` 同步传 `offsetTime`。验证:离屏 `.preflight/check` **39/39 通过**(连跑 3 次稳定;含像素级零变化断言与“真缩放会重绘”对照组);**A/B** 回退三项修复 ⇒ **7 项如实失败**(含 143513/137326 像素重绘症状);`make release` 零警告。 | | 2026-10-15 | DeepSeek-V4.1-Flash(**起源设备**) | **自适应范围/时机澄清 + 适应视图与 Y 轴自适应互不干扰 + 吸附跟随时间偏移(详见 §9.31)**。① **澄清“自适应范围/时机”**(修正记忆里“额外要求视界内至少有一个可见数据点”这句不完善的表述):**首个网格**显示内容时 **Y 轴与时间轴都**自适应;只有一个数据点的信号要**居中显示在网格里**(`FitSinglePointSignal`:居中 + 时间跨度 1s + Y 跨度 1);**后续新增网格只做 Y 轴自适应、不再调整时间轴**。② **「适应视图」与「Y 轴自适应」互相干扰**:根因是两条自适应路径**留白不同**(`PadRange` 6% vs `AutoRangeLaneToView` 5%)⇒ 视界未变时也会算出不同区间、网格被无意义重绘(实测切一次 **143513** 像素、反向 **137326** 像素)。修法:新增 `DataModel.hpp` 的 **`PadValueRange()`** 作为唯一留白实现(含退化区间 `max(1,|v|×0.1)`),`PadRange` 与 `AutoRangeLaneToView` 共用 ⇒ 四个步骤(适应视图→开自适应→关自适应→再适应视图)**区间逐位相同且绘图区逐像素 0 差异**。③ **「适应视图」在单点信号上让数据点“飞了”**:`FitAll()` 开头先试 `FitSinglePointSignal()`(仅一个网格 + 唯一可见信号为单点),与首次加载效果一致且幂等。④ **吸附跟随「调整 → 时间轴偏移」**:采样点画在 `time[k] + offsetTime` 处而光标存的是显示时间,旧实现按源时刻吸附 ⇒ 落到历史位置;修法:`SignalSnapEnabled`/`HasVisibleSample`/`NearestSnapSample`/`LocalSampleSpacing` 全部加 `timeShift`,查找时 `time - timeShift` 回源空间、返回时加回去,`UpdateAutoScatterStyles` 同步传 `offsetTime`。验证:离屏 `.preflight/check` **39/39 通过**(连跑 3 次稳定;含像素级零变化断言与“真缩放会重绘”对照组);**A/B** 回退三项修复 ⇒ **7 项如实失败**(含 143513/137326 像素重绘症状);`make release` 零警告。 |
| 2026-10-15 | DeepSeek-V4.1-Flash(**起源设备**) | **信号面板滚动条跟随 / 工具栏连点丢状态 / 图层网格线步长 / 多网格单点自适应 / 单点信号拖拽(详见 §9.32)**。① **滚动条"卡住"**:`Application::WM_MOUSEMOVE` 的 `plotOnlyMove` 只看 `inPlot && m_mouseOverPlot`,拖滚动条进绘图区后判定"只影响绘图区"⇒ 面板滚动值变了却不重绘;修法:新增 `draggingNonPlot`(正在拖非绘图区组件时一律完整失效)。② **工具栏丢点击**:窗口类带 `CS_DBLCLKS` ⇒ 快速第二次按下被合成 `WM_LBUTTONDBLCLK` 而工具栏没处理(**按下丢失**);且 `HandleMouseMove` 只要不在工具栏上就 `OnMouseLeave()` ⇒ 清掉 `m_pressedIndex`(按住划出再回按钮松手无效);两处都已修。③ **网格线错位/丢失/闪烁**:`PaintLane` 的竖向主刻步长取自 `Tab().ViewMin/ViewMax`,而渲染信号图层时该区间已被改成**宽窗口**(≈2×)⇒ 图层辅助线步长 2×、位置与前台不同、判重判据也错位;修法:新增 `m_renderTimeStep`,由 `RenderLaneLayerRegion` 用**前台跨度**算好传入。④ **多网格单点「适应视图」**:新增 `FitSinglePointLanes()`(左右边界 = 最左/最右数据点 ± **一个网格小格**;逐网格 Y 自适应、各留**一个 Y 小格**;全部点同刻 ⇒ 跨度 1s 居中;单值网格 ⇒ Y 跨度 1 居中)。⑤ **单点信号**:`HitTestSignal` 新增单点分支 + 环状光晕 + 可拖拽换网格(悬停**不改光标**,按下后才改)。验证:离屏 `.preflight/check` **67/67 通过**(连跑 3 次稳定);**A/B** 回退六处修复 ⇒ **17 项如实失败**(含 [C] 辅助线 49px vs 25px、20 vs 40 条、最大偏移 539px);`make release` 零警告、链接通过。 |
--- ---
@@ -2011,6 +2094,26 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸(
组名随最后一行**平滑滚出**(不突然消失),且下边界永不越过下一个分组行的自然位置 组名随最后一行**平滑滚出**(不突然消失),且下边界永不越过下一个分组行的自然位置
⇒ **不可能遮住下一个组名**;**折叠的分组不悬浮**(随滚动自然划过)。 ⇒ **不可能遮住下一个组名**;**折叠的分组不悬浮**(随滚动自然划过)。
行几何仍只由 `RowRect()` 提供(用 `m_rowYOverrideActive/m_rowYOverrideY` 覆盖顶部坐标)。 行几何仍只由 `RowRect()` 提供(用 `m_rowYOverrideActive/m_rowYOverrideY` 覆盖顶部坐标)。
64. ⚠️ **图层(宽基底)里的任何刻度步长都必须由"前台视界"显式传入**:
渲染图层时 `RenderLaneLayerRegion` 会把 `Tab().ViewMin/ViewMax` 改成**宽窗口**(≈2× 前台),
因此 `PaintLane` **绝不能**拿它去 `NiceStep` —— 竖向必须优先用 `m_renderTimeStep`
(由 `RenderLaneLayerRegion` 用前台跨度算好传入),横向必须用 `m_wideTickSpan/m_wideTickHeight`。
违反时:图层里的辅助线步长 2×、位置与前台不同、"与主刻重合则跳过"的判据也错位
⇒ 拖拽期间网格线错位/整条丢失,且在图层帧与结算帧之间**闪烁**(实测 49px vs 25px、20 vs 40 条)。
65. ⚠️ **拖动"非绘图区组件"(信号面板滚动条等)时,鼠标移动不得只失效绘图区**:
`plotOnlyMove` 必须先排除 `draggingNonPlot = (m_activeTarget != NONE) && != PLOT && != EDIT_FIELD`。
否则鼠标一进绘图区就只重绘绘图区,而组件自身的滚动偏移已经变了 ⇒ 滑块/滚动条"卡在原地不动"。
66. ⚠️ **工具栏必须把 `WM_LBUTTONDBLCLK` 当作"按下"处理**(窗口类带 `CS_DBLCLKS`:快速连点时第 2 次及以后
的按下会被合成成它,不处理就**完全丢失那一次点击**);并且**按住期间不得**因 `OnMouseLeave()` 清掉按下态
(`m_pressedIndex`)—— 否则"按住按钮划出边缘再回来松手"不会触发任何动作。
67. ⚠️ **单点信号(`finiteCount == 1`)必须在命中测试 / 光晕 / 拖拽虚影里单独处理**:
它没有折线可测距,`HitTestSignal` 要用 `SinglePointScreenPos()` + 容差 `max(常规容差, 点绘制半径)`;
悬停时**不改鼠标形状**(`QueryCursor` 不查 `HitTestSignal`),只在按下拖拽后改(`IDC_SIZEALL`)。
68. ⚠️ **多网格单点信号的「适应视图」规则**(用户 2026-10-15 明确给出):左/右边界 = 所有网格中
最左/最右数据点时间再向外偏**一个网格小格**的时间跨度;每个网格的 Y 按自身数据点自适应,
若该网格内存在"时间一致但取值不一致"的多个单点 ⇒ 纵向上下各留**一个 Y 小格**;
全部点在同一时刻 ⇒ 时间跨度 **1s** 居中于该时刻(与"首个单点信号初始视图"对齐);
某网格只有一个取值 ⇒ 该网格 Y 跨度 **1** 居中。仅在**所有可见信号都是单点**时启用。
### 11.3 离屏验证方法学(本机偏好,强烈建议沿用) ### 11.3 离屏验证方法学(本机偏好,强烈建议沿用)