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

用户 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

+315 -35
View File
@@ -1324,11 +1324,10 @@ namespace jdi
return;
}
// 「仅有一个网格、且网格内只显示了一个**单点**信号」时,适应效果必须与
// “首次加载并勾选该单点信号”时完全一致(数据点居中、时间跨度 1s、Y 跨度 1)。
// 否则整段自适应会让时间轴覆盖整条时间列 —— 数据点被挤到网格边缘、看起来“飞了”
//(用户 2026-10-15 报告)。
if (FitSinglePointSignal())
// 「网格内只显示单点信号」时的自适应(含“多个网格、每个网格只有一个数据点”):
// 必须保证所有数据点都在屏幕内(用户 2026-10-15 报告“多网格单点时自适应把点甩飞”)。
// 该函数也覆盖“只有一个网格 + 单点信号”的情形(时间跨度 1s、居中,与原行为一致)。
if (FitSinglePointLanes())
{
return;
}
@@ -1420,29 +1419,9 @@ namespace jdi
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))
double pointTime = 0.0;
double pointValue = 0.0;
if (!SinglePointOfSignal(*signal, *axis, pointTime, pointValue))
{
return false;
}
@@ -1479,6 +1458,201 @@ namespace jdi
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()
{
Tab().CursorMode() = PlotCursorMode::NONE;
@@ -4609,6 +4783,49 @@ namespace jdi
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 empty;
@@ -4644,6 +4861,30 @@ namespace jdi
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);
@@ -7442,7 +7683,7 @@ namespace jdi
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)
{
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<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);
if (!drawLine || breaks.empty())
{
@@ -7521,7 +7777,7 @@ namespace jdi
const PolylineCacheEntry* cached = AcquirePolyline(m_hoverSignal, resolved, lane);
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)];
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 double savedWideTickSpan = m_wideTickSpan;
const float savedWideTickHeight = m_wideTickHeight;
const double savedRenderTimeStep = m_renderTimeStep;
m_plotRect = gd::RectF(0.0f, 0.0f, static_cast<float>(bitmapWidth),
static_cast<float>(bitmapHeight));
@@ -9313,16 +9570,21 @@ namespace jdi
UiFillRect(g, m_area, m_theme->cardBg);
// 竖向网格刻度沿用**前台视界跨度**(而非宽窗口的跨度),否则竖网格线会只有一半。
// ⚠️ 同时把它作为竖向网格的**主刻步长**传给 `PaintLane`(见 `m_renderTimeStep`):
// 不传就会让图层用宽窗口跨度重算步长 → 竖向网格线与前台错位、部分整条丢失。
const double fgSpan = savedViewMax - savedViewMin;
const double timeStep = NiceStep((fgSpan > 0.0) ? fgSpan : 1.0, kTimeTickTarget);
m_renderTimeStep = timeStep;
std::vector<double> timeTicks;
BuildTicks(Tab().ViewMin(), Tab().ViewMax(), timeStep, timeTicks);
PaintLane(g, laneIndex, m_lanes[static_cast<std::size_t>(laneIndex)], timeTicks);
m_renderTimeStep = 0.0;
}
m_fastFrame = savedFastFrame;
m_renderWide = savedRenderWide;
m_wideTickSpan = savedWideTickSpan;
m_wideTickHeight = savedWideTickHeight;
m_renderTimeStep = savedRenderTimeStep;
m_stripRenderActive = savedStripActive;
m_stripX0 = savedStripX0;
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 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);
std::vector<double> timeMinorTicks;
BuildTicks(Tab().ViewMin(), Tab().ViewMax(), timeMinorStep, timeMinorTicks);
@@ -10074,7 +10341,7 @@ namespace jdi
const bool highlighted = (ref == haloRef);
if (highlighted)
{
PaintSignalHaloFor(g, resolved, *cached);
PaintSignalHaloFor(g, resolved, lane, *cached);
}
if (drawLine && !breaks.empty())
@@ -13172,6 +13439,19 @@ namespace jdi
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);
}