适应视图:撤回旧策略并重做边界规则(单值散点让出半径 + 单点簇视图)
用户 2026-10-15 第五轮报告的两个问题与要求(详见 Documents/agent_mem.md §9.33):
1. 加载文件后不勾选任何信号、连点几次「适应视图」,再勾选一个单值信号时,
初次自适应没有发生、数据点既不居中也不在网格内。
根因:FitAll() 在没有任何可见信号时也把 Tab().ViewInitialized() 置为 true,
随后勾选第一个信号时 Application::SynchronizeView 认为"已经初始化",
于是跳过自适应,视界停在"无信号"那一次的整段范围上(实测 MF4 为 42.386s,
数据点被推到绘图区右边缘之外)。
修法:FitAll() 只有在存在可见信号时才置位 ViewInitialized();
并让"初次自适应"与工具栏「适应视图」共用 FitAll() 这一条实现
(不再先试 FitSinglePointSignal)。
2. 仅勾选 WIN_Motor_StallCurrentVolBrk(单值信号)与 m_MOT_Current_DCom[0](连续信号)
时点「适应视图」,单值信号的点被挤到显示区域外只剩半个;上一轮"为左右空出一小格"
的需求效果不佳。
修法(按用户要求先撤回旧策略、再重做规则):
* 撤回并删除 FitSinglePointLanes()(左右各留一小格)、TimeGridCellSeconds()、
LaneValueGridCell()、FitSinglePointSignal()(首个单值信号的时间跨度 1s 视图)。
* 新规则:常规全局适应后,任何一条边界(时间轴左/右、每个网格幅值上/下)
若被一个单值信号的数据点顶住,就把该边界向外让出该散点的半径(+1 像素),
使其完整显示在网格内;只在散点会被裁切时才扩展(内部散点使视界一点不动);
边界不是单值信号的数据点时就落在该信号点上(时间轴不留额外余量)。
判断依据用信号自身的数据点,而不是时间轴范围(MF4 里单值信号与连续信号
常共用同一条时间轴,用轴的范围会误判)。
* 特例:全部可见数据点都是单值信号且时刻完全相同时,时间轴以该时刻居中、
缩放到一大格 = 1s(视界 = kTimeTickTarget × 1s = 10s,此时 NiceStep(10,10) 恰为 1.0),
每个网格的幅值区间以"能完整画出该网格内的全部数据点"为准
(退化成单值时以跨度 1 居中)。
* 新增 FitContribution / CollectFitContributions / FitLaneHeight /
ApplySinglePointValueMargins / FitSinglePointCluster 五个成员承载上述规则。
验证(全部离屏,不占用鼠标 / 键盘 / 屏幕):
- .preflight/check(SW_HIDE + 只注入合成消息 + 进程/线程 BELOW_NORMAL)**62/62 通过**,
连跑 3 次稳定:[A] 初次自适应、[B] 时间边界(外扩量恰为半径 + 1px)、
[C] 幅值边界、[D] 单点簇特例(跨度 10、一大格 1s、每个点都在网格内)、
[E] 回归(普通信号视界 = 数据范围、网格保持自动自适应、§9.31 的
"适应视图 ↔ Y 轴自适应互不干扰"仍逐位成立)。
- [F] 真实文件 TestData/MF4-Record.MF4:WIN_Motor_StallCurrentVolBrk 确实只有 1 个有效点
(t = 0.005492),与 m_MOT_Current_DCom[0](8475 样点)复现用户场景 —— 点「适应视图」后
散点圆心离绘图区左边界 16.5px、半径 15px,完整可见(修复前它正好压在边界上、只剩半个)。
- A/B 判别:回退四处规则后 **20 项如实失败**,失败信息即用户症状
("the dot is horizontally centred (got 1697, want 1084.75)"、
"[F] initial view = one big cell per second (got 42.386313, want 10.000000)")。
- make release:全部源文件重新编译 **零警告**;链接用临时 `make release TARGET_FILE_NAME=Target_check` 通过。
同步记忆:Documents/agent_mem.md 新增 §9.33、§11.2 第 69–71 条硬约束(并把第 68 条与
§9.32 的第四小节显式标注为"已作废/被取代")、变更日志新行。
This commit is contained in:
1 parent
aee3a388b5
commit
73389f424c
4 files changed
+470
-282
No files matched your search
@@ -627,12 +627,9 @@ namespace jdi
|
||||
}
|
||||
else if (!tab.ViewInitialized())
|
||||
{
|
||||
// 「单点信号」有专门的初始视图(数据点居中、时间跨度 1s、幅值跨度 1):
|
||||
// 整段自适应对它不适用(时间轴会覆盖整条时间列、幅值区间退化成几乎为 0)。
|
||||
if (!m_plot.FitSinglePointSignal())
|
||||
{
|
||||
m_plot.FitAll();
|
||||
}
|
||||
// 首次出现可见信号时做一次自适应。单点信号(时间/幅值范围退化成 0)由
|
||||
// `FitAll` 内部按"单值散点"的规则处理(居中 + 一大格 1s),这里不再分开判断。
|
||||
m_plot.FitAll();
|
||||
}
|
||||
|
||||
const bool laneCountChanged = (laneCount != previousLaneCount);
|
||||
|
||||
@@ -430,40 +430,68 @@ namespace jdi
|
||||
HCURSOR QueryCursor(int x, int y) const;
|
||||
|
||||
/**
|
||||
* @brief 时间轴与幅值轴同时自适应到可见信号范围。
|
||||
* @brief 「适应视图」:时间轴与幅值轴同时自适应到可见信号范围。
|
||||
*
|
||||
* 边界规则(用户 2026-10-15 第五轮**重新定义**,取代此前"左右各留一小格"的旧做法):
|
||||
* * 常规的全局适应后,若某条边界(时间轴的左 / 右、每个网格幅值的上 / 下)被一个
|
||||
* **单值信号的数据点**顶住,则该边界再向外让出**该散点的半径**,使它**完整地
|
||||
* 显示在网格内**;边界若不是单值信号的数据点,则边界就落在这个信号点上
|
||||
* (时间轴不留额外余量,幅值仍走 `PadValueRange` 的统一留白)。
|
||||
* * 唯一特例:**所有可见信号都是单值数据点、且时刻完全相同**时,时间轴以该时刻
|
||||
* 居中并缩放到"一大格 = 1s",每个网格的幅值区间保证其数据点都能完整画出。
|
||||
*
|
||||
* ⚠️ 没有任何可见信号时**不置位** `Tab().ViewInitialized()`:用户先连点几次
|
||||
* 「适应视图」后再勾选第一个信号,初次自适应仍必须发生。
|
||||
*/
|
||||
void FitAll();
|
||||
|
||||
/**
|
||||
* @brief 「单点信号」的初始视图:把唯一的数据点居中,时间跨度 1s、幅值跨度 1。
|
||||
*
|
||||
* 用户要求(2026-10-15):当前**没有任何信号被勾选**、而勾选的第一个信号
|
||||
* **只有一个有效数据点**时,整段自适应并不能把它的状态显示出来
|
||||
*(时间轴会覆盖整条时间列、幅值区间退化成几乎为 0)。
|
||||
* 因此这种情况改为:数据点居中放置,**时间轴跨度 = 一个大格 = 1s**、
|
||||
* **Y 轴跨度 = 一个大格 = 1**。
|
||||
*
|
||||
* @return 已按“单点信号”初始化返回 true(调用方无需再做 `FitAll`)。
|
||||
* @brief 一个**可见信号**在「适应视图」里提供的边界贡献(见 `FitAll` 的边界规则)。
|
||||
*/
|
||||
bool FitSinglePointSignal();
|
||||
struct FitContribution
|
||||
{
|
||||
SignalRef ref; /**< 信号引用。 */
|
||||
double minTime = 0.0; /**< 时间轴下限(源时刻,与 `ComputeVisibleTimeBounds` 同源)。 */
|
||||
double maxTime = 0.0; /**< 时间轴上限。 */
|
||||
bool singlePoint = false; /**< 是否只有一个有效数据点(画出来就是一个圆点)。 */
|
||||
double pointTime = 0.0; /**< 单值时:数据点的源时刻。 */
|
||||
double pointValue = 0.0; /**< 单值时:**绘制变换后**的取值。 */
|
||||
float dotRadius = 0.0f; /**< 单值时:散点半径(设备像素)。 */
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief 「网格内只显示单点信号」时的自适应(含多个网格各只有一个数据点)。
|
||||
*
|
||||
* 用户要求(2026-10-15):多网格、每个网格只有一个数据点时,适应视图必须保证
|
||||
* **所有散点都在屏幕内**:
|
||||
* * 时间轴左边界 = 所有点中最早的时刻 **再向左偏移一小格**(一小格 = 该视界下
|
||||
* 主刻度 / 五等分,与网格叠底同源);右边界同理向右偏移一小格;
|
||||
* * 若**所有点都在同一时刻**(最极端情况),按“首个网格加载为单点信号”的方式对齐
|
||||
* (时间跨度 1s、居中);
|
||||
* * 逐网格的 Y 轴:网格内若有**取值不同**的多个单点信号,纵向同样在各边界留出一小格
|
||||
* (`LaneValueGridCell`);只有一个取值时跨度 1、居中。
|
||||
*
|
||||
* 任一网格里存在非单点的可见信号时**不适用**(返回 false,交回常规整段自适应)。
|
||||
*
|
||||
* @return 已按单点规则完成自适应返回 true。
|
||||
* @brief 汇总全部可见信号的 `FitContribution`(按网格分组)。
|
||||
* @param [OUT] perLane 与 `Tab().Lanes()` 同序的分组结果。
|
||||
* @return 可见信号总数(0 表示当前没有任何可见信号)。
|
||||
*/
|
||||
bool FitSinglePointLanes();
|
||||
std::size_t CollectFitContributions(std::vector<std::vector<FitContribution>>& perLane) const;
|
||||
|
||||
/**
|
||||
* @brief 当前布局下**单个网格的高度**(设备像素)。
|
||||
*
|
||||
* 与 `BuildLaneVisuals()` 用同一公式(而不是读 `m_lanes[i].rect.Height`):
|
||||
* `FitAll` 可能在网格数量刚变化、布局尚未重建时被调用,公式给出"将要生效"的值。
|
||||
* @return 网格高度;无网格时返回 0。
|
||||
*/
|
||||
float FitLaneHeight() const;
|
||||
|
||||
/**
|
||||
* @brief 若该网格的**幅值上/下边界**被单值散点顶住,把区间向外让出散点半径。
|
||||
*
|
||||
* 只在散点**会被裁切**时才改动区间("散点本来就完整可见"不动它,保持自动自适应的
|
||||
* 语义);改动时把 `autoRange` 置 false 并写入显式区间。
|
||||
*/
|
||||
void ApplySinglePointValueMargins(int laneIndex, const std::vector<FitContribution>& items,
|
||||
float laneHeight);
|
||||
|
||||
/**
|
||||
* @brief 「所有可见数据点都是单值信号、且时刻完全相同」时的自适应。
|
||||
*
|
||||
* 时间轴以 `sharedTime` 居中、**一大格 = 1s**;每个网格的幅值区间保证其数据点
|
||||
* 完整显示(网格内只有一个取值时以跨度 1 居中)。
|
||||
*/
|
||||
void FitSinglePointCluster(const std::vector<std::vector<FitContribution>>& perLane,
|
||||
double sharedTime);
|
||||
|
||||
/**
|
||||
* @brief 取某信号**唯一的有效数据点**(时刻 + 原始取值)。
|
||||
@@ -478,21 +506,6 @@ namespace jdi
|
||||
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 以给定倍率缩放时间轴(以视图中心为锚点)。
|
||||
* @param [IN] factor 缩放倍率,大于 1 表示放大。
|
||||
|
||||
@@ -156,6 +156,31 @@ namespace jdi
|
||||
*/
|
||||
const std::size_t kSnapScanWindowFactor = 64;
|
||||
|
||||
/**
|
||||
* @brief 「所有可见数据点都是单值信号、且时刻完全相同」时,时间轴**一个大格**对应的秒数。
|
||||
*
|
||||
* 用户 2026-10-15 明确要求:这种最极端情况下时间轴以该时刻居中、并缩放到
|
||||
* **一大格 = 1s** 的级别。视界跨度取 `kTimeTickTarget` 个大格 ⇒
|
||||
* 跨度 = 10s、`NiceStep(10, kTimeTickTarget)` = **1.0 秒**,正好是"一大格 1s"。
|
||||
*/
|
||||
const double kSinglePointClusterSecondsPerCell = 1.0;
|
||||
|
||||
/**
|
||||
* @brief 让边界处的单值散点"完整显示"时,在半径之外额外多留的像素(设备像素)。
|
||||
*
|
||||
* 只让出**正好等于半径**的量会让圆点边缘贴着网格边框(抗锯齿边缘看起来被切了一刀),
|
||||
* 因此再多留 1 个像素。
|
||||
*/
|
||||
const float kSinglePointBoundaryPadPixels = 1.0f;
|
||||
|
||||
/**
|
||||
* @brief 判定"多个单值数据点处于同一时刻"的相对误差。
|
||||
*
|
||||
* 同一列里读出的时刻是完全相同的 double;但不同来源(例如 MF4 的整数刻度 ×
|
||||
* 通道组的标定系数)可能差 1ulp,因此用相对误差而不是精确相等。
|
||||
*/
|
||||
const double kSinglePointSameTimeEpsilon = 1.0e-9;
|
||||
|
||||
const float kLaneLabelPad = 6.0f; /**< 网格内侧标尺带的内边距。 */
|
||||
const float kLegendHeight = 19.0f; /**< 图例条目高度(逻辑像素)。 */
|
||||
const float kLegendPad = 5.0f; /**< 图例相对网格左上角的内缩。 */
|
||||
@@ -1315,23 +1340,69 @@ namespace jdi
|
||||
BuildLaneVisuals();
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief 「适应视图」:时间轴与幅值轴同时自适应到可见信号范围。
|
||||
*
|
||||
* 边界规则(用户 2026-10-15 第五轮**重新定义**,取代此前"左右各留一小格"的做法):
|
||||
* * 先做一次常规的全局适应(时间 = 可见信号的整段时间范围;幅值走
|
||||
* `ComputeLaneRange`,含 `PadValueRange` 的统一留白);
|
||||
* * 若某条边界(时间轴的左 / 右、每个网格幅值的上 / 下)被一个**单值信号的
|
||||
* 数据点**顶住,则把该边界再向外让出**该散点的半径**(+1 像素),使它
|
||||
* **完整地显示在网格内**;边界若不是单值信号的数据点,则边界就落在这个信号点上
|
||||
* (时间轴不留额外余量);
|
||||
* * 唯一特例:**所有可见信号都是单值数据点、且时刻完全相同**时,时间轴以该时刻
|
||||
* 居中并缩放到"一大格 = 1s"(`kSinglePointClusterSecondsPerCell`),
|
||||
* 每个网格的幅值区间保证该网格内的数据点都能完整画出。
|
||||
*/
|
||||
void PlotView::FitAll()
|
||||
{
|
||||
if (m_dataSet == nullptr || m_dataSet->Empty())
|
||||
{
|
||||
SetTimeView(0.0, 1.0);
|
||||
Tab().ViewInitialized() = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// 「网格内只显示单点信号」时的自适应(含“多个网格、每个网格只有一个数据点”):
|
||||
// 必须保证所有数据点都在屏幕内(用户 2026-10-15 报告“多网格单点时自适应把点甩飞”)。
|
||||
// 该函数也覆盖“只有一个网格 + 单点信号”的情形(时间跨度 1s、居中,与原行为一致)。
|
||||
if (FitSinglePointLanes())
|
||||
if (m_tab == nullptr)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 全部可见信号的"边界贡献"(时刻范围 / 绘制取值 / 是否单点 / 散点半径)。
|
||||
std::vector<std::vector<FitContribution>> perLane;
|
||||
const std::size_t visibleCount = CollectFitContributions(perLane);
|
||||
|
||||
// ---- 特例:所有可见数据点都是单值信号、且时刻完全相同 ----
|
||||
bool allSingle = true;
|
||||
bool sameTime = true;
|
||||
double sharedTime = 0.0;
|
||||
bool haveTime = false;
|
||||
for (const std::vector<FitContribution>& items : perLane)
|
||||
{
|
||||
for (const FitContribution& item : items)
|
||||
{
|
||||
if (!item.singlePoint)
|
||||
{
|
||||
allSingle = false;
|
||||
continue;
|
||||
}
|
||||
if (!haveTime)
|
||||
{
|
||||
sharedTime = item.pointTime;
|
||||
haveTime = true;
|
||||
}
|
||||
else if (std::fabs(item.pointTime - sharedTime) >
|
||||
kSinglePointSameTimeEpsilon * std::max(1.0, std::fabs(sharedTime)))
|
||||
{
|
||||
sameTime = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (visibleCount > 0 && allSingle && sameTime)
|
||||
{
|
||||
FitSinglePointCluster(perLane, sharedTime);
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- 常规的整段适应 ----
|
||||
double low = 0.0;
|
||||
double high = 1.0;
|
||||
TimeBounds(low, high);
|
||||
@@ -1342,8 +1413,48 @@ namespace jdi
|
||||
high += padding;
|
||||
}
|
||||
|
||||
// 时间边界处的单值散点不能被裁切:**被裁切的那一侧**向外让出散点半径。
|
||||
// ⚠️ 迭代两遍:让出余量会改变跨度,而"半径换算成多少时间"又依赖跨度。
|
||||
const double plotWidth = std::max(1.0, static_cast<double>(m_plotRect.Width));
|
||||
for (int pass = 0; pass < 2; ++pass)
|
||||
{
|
||||
const double span = high - low;
|
||||
if (!(span > 0.0))
|
||||
{
|
||||
break;
|
||||
}
|
||||
for (const std::vector<FitContribution>& items : perLane)
|
||||
{
|
||||
for (const FitContribution& item : items)
|
||||
{
|
||||
if (!item.singlePoint || !(item.dotRadius > 0.0f))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
const double radius =
|
||||
(static_cast<double>(item.dotRadius) + kSinglePointBoundaryPadPixels) * span /
|
||||
plotWidth;
|
||||
if (item.pointTime - radius < low)
|
||||
{
|
||||
low = item.pointTime - radius;
|
||||
}
|
||||
if (item.pointTime + radius > high)
|
||||
{
|
||||
high = item.pointTime + radius;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SetTimeView(low, high);
|
||||
Tab().ViewInitialized() = true;
|
||||
// ⚠️ **只有真的存在可见信号时才置位 `ViewInitialized()`**:没有任何信号被勾选时
|
||||
//(用户先连点几次「适应视图」)必须保持"未初始化",否则随后勾选第一个信号时
|
||||
// `Application::SynchronizeView` 会**跳过初次自适应** —— 数据点既不居中,
|
||||
// 还可能有一半被裁在网格外(用户 2026-10-15 报告)。
|
||||
if (visibleCount > 0)
|
||||
{
|
||||
Tab().ViewInitialized() = true;
|
||||
}
|
||||
Tab().LaneScroll() = 0.0f;
|
||||
// 只放下进行中的选区与拖拽,**不改变「选区放大」等缩放模式的启用状态**
|
||||
// (用户明确要求:点适应视图后仍保持已启用的缩放模式)。
|
||||
@@ -1356,106 +1467,246 @@ namespace jdi
|
||||
? std::wstring()
|
||||
: L"横向或纵向拖动以放大选择区域,ESC退出选区模式");
|
||||
|
||||
if (m_tab != nullptr)
|
||||
// ---- 逐网格的幅值轴 ----
|
||||
// 保留既有语义:开启了「选区 Y 轴自适应」的网格按新的(整段)时间视图重新匹配,
|
||||
// 其余网格回到整段自适应;随后再为"被单值散点顶住的边界"让出余量。
|
||||
const float laneHeight = FitLaneHeight();
|
||||
for (std::size_t index = 0; index < m_tab->Lanes().size(); ++index)
|
||||
{
|
||||
// 适应视图 = 回到初始状态:恢复整段数据的自动自适应。
|
||||
// 但**开启了「选区 Y 轴自适应」的网格必须继续跟随时间视图**:
|
||||
// 直接置 autoRange 会让 ComputeLaneRange 忽略已经算好的时间段幅值,
|
||||
// 表现为"点一下适应视图就把 Y 轴自适应关掉了"。这里改为按新的
|
||||
// (整段)时间视图重新匹配一次,开关状态与视觉效果都保持。
|
||||
const int laneCount = static_cast<int>(m_tab->Lanes().size());
|
||||
for (int index = 0; index < laneCount; ++index)
|
||||
const int laneIndex = static_cast<int>(index);
|
||||
if (m_tab->Lanes()[index].fitYToSelection)
|
||||
{
|
||||
if (m_tab->Lanes()[static_cast<std::size_t>(index)].fitYToSelection)
|
||||
{
|
||||
AutoRangeLaneToView(index);
|
||||
}
|
||||
else
|
||||
{
|
||||
m_tab->Lanes()[static_cast<std::size_t>(index)].autoRange = true;
|
||||
}
|
||||
AutoRangeLaneToView(laneIndex);
|
||||
}
|
||||
else
|
||||
{
|
||||
m_tab->Lanes()[index].autoRange = true;
|
||||
}
|
||||
ApplySinglePointValueMargins(laneIndex, perLane[index], laneHeight);
|
||||
}
|
||||
// 适应视图已回到初始状态,这里不再移动光标(光标允许停留在可视区外)。
|
||||
BuildLaneVisuals();
|
||||
}
|
||||
|
||||
bool PlotView::FitSinglePointSignal()
|
||||
std::size_t PlotView::CollectFitContributions(std::vector<std::vector<FitContribution>>& perLane) const
|
||||
{
|
||||
if (m_tab == nullptr || m_dataSet == nullptr || m_dataSet->Empty())
|
||||
perLane.clear();
|
||||
if (m_tab == nullptr || m_dataSet == nullptr)
|
||||
{
|
||||
return false;
|
||||
return 0;
|
||||
}
|
||||
perLane.assign(m_tab->Lanes().size(), std::vector<FitContribution>());
|
||||
|
||||
// 只在「当前没有任何信号被勾选,勾选的第一个信号只有一个有效数据点」时生效
|
||||
// (用户 2026-10-15 明确要求):此时整段自适应会让时间轴覆盖整条时间列、
|
||||
// Y 轴退化成几乎为 0 的区间,数据点既不在中心也看不出来。
|
||||
SignalRef only;
|
||||
int visibleCount = 0;
|
||||
for (const Lane& lane : m_tab->Lanes())
|
||||
std::size_t total = 0;
|
||||
for (std::size_t laneIndex = 0; laneIndex < m_tab->Lanes().size(); ++laneIndex)
|
||||
{
|
||||
for (const SignalRef& ref : lane.signals)
|
||||
for (const SignalRef& ref : m_tab->Lanes()[laneIndex].signals)
|
||||
{
|
||||
if (!m_tab->IsVisible(ref))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
only = ref;
|
||||
if (++visibleCount > 1)
|
||||
const SignalData* data = m_dataSet->Resolve(ref);
|
||||
const TimeAxis* axis = m_dataSet->Axis(ref);
|
||||
if (data == nullptr || axis == nullptr || data->finiteCount == 0)
|
||||
{
|
||||
return false; // 多个可见信号:按常规的整段自适应处理
|
||||
continue;
|
||||
}
|
||||
|
||||
FitContribution item;
|
||||
item.ref = ref;
|
||||
// 与 `ComputeVisibleTimeBounds` 同源:时间边界用**时间轴**的范围
|
||||
//(一个分组的多条信号可能共用一条轴,轴范围才是"这一段数据"的边界)。
|
||||
item.minTime = axis->minTime;
|
||||
item.maxTime = axis->maxTime;
|
||||
item.singlePoint = (data->finiteCount == 1);
|
||||
if (item.singlePoint)
|
||||
{
|
||||
double time = 0.0;
|
||||
double value = 0.0;
|
||||
if (!SinglePointOfSignal(*data, *axis, time, value))
|
||||
{
|
||||
item.singlePoint = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
const SignalStyle* style = m_dataSet->Style(ref);
|
||||
item.pointTime = time;
|
||||
item.pointValue =
|
||||
(style != nullptr) ? ApplyDrawTransform(*style, value) : value;
|
||||
item.dotRadius = SinglePointDotRadius(ResolveSignal(ref));
|
||||
}
|
||||
}
|
||||
perLane[laneIndex].push_back(item);
|
||||
++total;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
float PlotView::FitLaneHeight() const
|
||||
{
|
||||
// 与 `BuildLaneVisuals()` **完全同源**:网格高度 = 绘图区高 / 网格数(不低于最小高度)。
|
||||
// 直接复用它(而不是读 `m_lanes[i].rect.Height`):`FitAll` 可能在网格数量刚变化、
|
||||
// 布局尚未重建时被调用,用公式能拿到**将要生效**的高度。
|
||||
const std::size_t count = (m_tab != nullptr) ? m_tab->Lanes().size() : 0;
|
||||
if (count == 0 || !(m_plotRect.Height > 0.0f))
|
||||
{
|
||||
return 0.0f;
|
||||
}
|
||||
float height = m_plotRect.Height / static_cast<float>(count);
|
||||
if (height < m_minLaneHeight * m_scale)
|
||||
{
|
||||
height = m_minLaneHeight * m_scale;
|
||||
}
|
||||
return height;
|
||||
}
|
||||
|
||||
void PlotView::ApplySinglePointValueMargins(int laneIndex, const std::vector<FitContribution>& items,
|
||||
float laneHeight)
|
||||
{
|
||||
if (m_tab == nullptr || m_dataSet == nullptr || laneHeight <= 1.0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool anySingle = false;
|
||||
for (const FitContribution& item : items)
|
||||
{
|
||||
if (item.singlePoint && item.dotRadius > 0.0f)
|
||||
{
|
||||
anySingle = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!anySingle)
|
||||
{
|
||||
return; // 该网格的边界不是单值散点顶住的:保持现有(自动)区间
|
||||
}
|
||||
|
||||
// 该网格当前**生效**的幅值区间(自动自适应时即 `ComputeLaneRange` 的结果)。
|
||||
double low = 0.0;
|
||||
double high = 0.0;
|
||||
m_tab->ComputeLaneRange(*m_dataSet, laneIndex, low, high);
|
||||
if (!(high > low))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
double newLow = low;
|
||||
double newHigh = high;
|
||||
// 迭代两遍:让出余量会改变跨度,而"半径换算成多少数值"又依赖跨度。
|
||||
for (int pass = 0; pass < 2; ++pass)
|
||||
{
|
||||
const double span = newHigh - newLow;
|
||||
if (!(span > 0.0))
|
||||
{
|
||||
break;
|
||||
}
|
||||
for (const FitContribution& item : items)
|
||||
{
|
||||
if (!item.singlePoint || !(item.dotRadius > 0.0f))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
const double radius =
|
||||
(static_cast<double>(item.dotRadius) + kSinglePointBoundaryPadPixels) * span /
|
||||
static_cast<double>(laneHeight);
|
||||
if (item.pointValue - radius < newLow)
|
||||
{
|
||||
newLow = item.pointValue - radius;
|
||||
}
|
||||
if (item.pointValue + radius > newHigh)
|
||||
{
|
||||
newHigh = item.pointValue + radius;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (visibleCount != 1)
|
||||
if (newLow == low && newHigh == high)
|
||||
{
|
||||
return false;
|
||||
return; // 散点本来就完整可见:不改动区间(保持自动自适应的语义)
|
||||
}
|
||||
|
||||
const SignalData* signal = m_dataSet->Resolve(only);
|
||||
const TimeAxis* axis = m_dataSet->Axis(only);
|
||||
if (signal == nullptr || axis == nullptr || signal->finiteCount != 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// ⚠️ 必须**同时**关掉 `autoRange`:否则 `ComputeLaneRange` 会忽略下面写入的区间。
|
||||
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
|
||||
lane.autoRange = false;
|
||||
lane.yMin = newLow;
|
||||
lane.yMax = newHigh;
|
||||
}
|
||||
|
||||
double pointTime = 0.0;
|
||||
double pointValue = 0.0;
|
||||
if (!SinglePointOfSignal(*signal, *axis, pointTime, pointValue))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 时间轴:跨度 = **一个大格(1s)**,数据点居中。
|
||||
const double halfTimeSpan = 0.5;
|
||||
SetTimeView(pointTime - halfTimeSpan, pointTime + halfTimeSpan);
|
||||
void PlotView::FitSinglePointCluster(const std::vector<std::vector<FitContribution>>& perLane,
|
||||
double sharedTime)
|
||||
{
|
||||
// 时间轴:该时刻居中、**一大格 = 1s**(视界 = 大格数 × 1s)。
|
||||
const double span =
|
||||
static_cast<double>(kTimeTickTarget) * kSinglePointClusterSecondsPerCell;
|
||||
SetTimeView(sharedTime - span * 0.5, sharedTime + span * 0.5);
|
||||
Tab().ViewInitialized() = true;
|
||||
Tab().LaneScroll() = 0.0f;
|
||||
Tab().ZoomMode() = PlotZoomMode::NONE;
|
||||
// ⚠️ **不要**在这里复位 `ZoomMode()`:用户明确要求"点适应视图后仍保持已启用的
|
||||
// 缩放模式"(`FitAll` 的常规路径同样只更新提示、不动缩放模式)。
|
||||
m_rubberRect = gd::RectF();
|
||||
m_dragMode = PlotDragMode::NONE;
|
||||
m_dragSignal = SignalRef();
|
||||
m_dragTargetLane = -1;
|
||||
m_hoverSignal = SignalRef();
|
||||
m_rubberAxis = PlotRubberAxis::UNDECIDED;
|
||||
SetHint(std::wstring());
|
||||
SetHint(Tab().ZoomMode() == PlotZoomMode::NONE
|
||||
? std::wstring()
|
||||
: L"横向或纵向拖动以放大选择区域,ESC退出选区模式");
|
||||
|
||||
// Y 轴:跨度 = **一个大格(1)**,数据点居中。
|
||||
// ⚠️ 必须同时关掉 `autoRange`(否则会被整段自适应覆盖)与 `fitYToSelection`
|
||||
//(选区自适应会按可见区间算出退化区间;全局 Y 轴自适应开启时那条路径也会生效)。
|
||||
const double halfValueSpan = 0.5;
|
||||
for (Lane& lane : m_tab->Lanes())
|
||||
const float laneHeight = FitLaneHeight();
|
||||
for (std::size_t index = 0; index < perLane.size() && index < m_tab->Lanes().size(); ++index)
|
||||
{
|
||||
if (lane.signals.size() != 1 || !(lane.signals.front() == only))
|
||||
const std::vector<FitContribution>& items = perLane[index];
|
||||
double valueLow = 0.0;
|
||||
double valueHigh = 0.0;
|
||||
float radius = 0.0f;
|
||||
bool any = false;
|
||||
for (const FitContribution& item : items)
|
||||
{
|
||||
continue;
|
||||
if (!item.singlePoint || !std::isfinite(item.pointValue))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
valueLow = any ? std::min(valueLow, item.pointValue) : item.pointValue;
|
||||
valueHigh = any ? std::max(valueHigh, item.pointValue) : item.pointValue;
|
||||
radius = std::max(radius, item.dotRadius);
|
||||
any = true;
|
||||
}
|
||||
if (!any)
|
||||
{
|
||||
continue; // 该网格没有可见单值信号:保持原样
|
||||
}
|
||||
|
||||
// 幅值跨度:以容纳"网格内的全部数据点"为下限(退化成单值时取 1,
|
||||
// 与"单值信号居中显示"的观感一致),并保证**每个散点都完整在网格内**。
|
||||
double range = std::max(1.0, valueHigh - valueLow);
|
||||
if (laneHeight > 1.0f && radius > 0.0f)
|
||||
{
|
||||
for (int pass = 0; pass < 2; ++pass)
|
||||
{
|
||||
const double margin =
|
||||
(static_cast<double>(radius) + kSinglePointBoundaryPadPixels) * range /
|
||||
static_cast<double>(laneHeight);
|
||||
range = std::max(1.0, valueHigh - valueLow + 2.0 * margin);
|
||||
}
|
||||
}
|
||||
const double center = (valueLow + valueHigh) * 0.5;
|
||||
|
||||
Lane& lane = m_tab->Lanes()[index];
|
||||
lane.autoRange = false;
|
||||
lane.fitYToSelection = false;
|
||||
lane.yMin = pointValue - halfValueSpan;
|
||||
lane.yMax = pointValue + halfValueSpan;
|
||||
lane.yMin = center - range * 0.5;
|
||||
lane.yMax = center + range * 0.5;
|
||||
}
|
||||
|
||||
InvalidateSceneCache();
|
||||
BuildLaneVisuals();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool PlotView::SinglePointOfSignal(const SignalData& signal, const TimeAxis& axis, double& time,
|
||||
@@ -1486,173 +1737,6 @@ namespace jdi
|
||||
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;
|
||||
|
||||
+96
-2
@@ -1749,6 +1749,11 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸(
|
||||
|
||||
#### 四、多个网格、每个网格只有一个数据点时「适应视图」
|
||||
|
||||
> ⚠️⚠️ **本节规则(“左右各留一小格” + `FitSinglePointLanes()`)已被 §9.33 作废/取代**
|
||||
> (用户 2026-10-15 第五轮:“首先应该对之前的策略做撤回,然后重新设计新的规则”)。
|
||||
> 保留本节仅为历史脉络:现行规则见 **§9.33**(边界处的单值散点让出**半径**,
|
||||
> 而不是“留一小格”)。
|
||||
|
||||
用户给出的规则(逐条落地在 `PlotView::FitSinglePointLanes()`):
|
||||
|
||||
* **时间轴**:左边界 = 所有网格中**最左**的数据点时间,再向左偏**一个网格小格**对应的时间跨度
|
||||
@@ -1789,6 +1794,75 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸(
|
||||
|
||||
---
|
||||
|
||||
### 9.33 「适应视图」边界规则**撤回重做**:单值散点让出半径 + 单点簇视图(本轮,起源设备)
|
||||
|
||||
> 本轮(用户 2026-10-15 第五轮)。用户原话要点:
|
||||
> ① 加载文件后**不勾选任何信号**时点几次「适应视图」,再勾选一个单值信号 —— 初次自适应**没有发生**
|
||||
> (数据点既不居中也不在网格内);② 只勾选 `WIN_Motor_StallCurrentVolBrk`(单值)+ `m_MOT_Current_DCom[0]`
|
||||
> (连续)时点「适应视图」,单值信号的点被挤到显示区域外**只剩半个**;
|
||||
> ③ **撤撤回上一轮的策略**(§9.32 的"左右各留一小格"),重新设计边界规则。
|
||||
> 离屏验证 `.preflight/check` **62/62 通过**(连跑 3 次稳定);**A/B** 回退四处规则后 **20 项如实失败**
|
||||
> (含 `the dot is horizontally centred (got 1697, want 1084.75)` = 散点正好压在绘图区**右边缘**、
|
||||
> `[F] initial view (got 42.386313, want 10)` = 初次自适应压根没发生)。
|
||||
|
||||
#### 一、撤回的旧策略(勿再用)
|
||||
|
||||
- 删除 `PlotView::FitSinglePointLanes()`("左右边界 = 最左/最右数据点 ± **一个网格小格**")。
|
||||
- 删除 `PlotView::TimeGridCellSeconds()` / `PlotView::LaneValueGridCell()`(只为它服务)。
|
||||
- 删除 `PlotView::FitSinglePointSignal()`("首个单值信号:时间跨度 1s + Y 跨度 1")——
|
||||
初次自适应现在与工具栏「适应视图」**走同一条路径**(`FitAll()`),规则不会再出现两套。
|
||||
|
||||
#### 二、新的边界规则(`PlotView::FitAll()`)
|
||||
|
||||
1. 先做一次**常规**的全局适应:
|
||||
* 时间 = `TimeBounds()`(可见信号的**时间轴**范围并集,与 `ComputeVisibleTimeBounds` 同源);
|
||||
* 幅值 = `ComputeLaneRange()`(含唯一的留白实现 `PadValueRange`,即 **6%**)。
|
||||
2. **单值散点让位**(上下左右四条边界统一规则):
|
||||
* 若某条边界被一个**单值信号的数据点**(`finiteCount == 1`,画出来是一个圆点)顶住,
|
||||
则把该边界再向外让出**该散点的半径**(`SinglePointDotRadius()`,再 +1 像素;
|
||||
两个方向都迭代两遍,因为"半径换算成多少时间/数值"依赖当前跨度);
|
||||
* **只在散点会被裁切时**才扩展(散点本来就在内部 ⇒ 视界**一点不动**);
|
||||
* 边界不是单值散点时 ⇒ 边界就落在这个信号点上,**不留额外余量**
|
||||
(时间轴本来就不留;幅值仍沿用 `PadValueRange` 的 6%,这是 §11.2 第 62 条确立的
|
||||
"幅值留白只能有一个来源",本次**没有**改动它)。
|
||||
* 判断依据是**信号自身的数据点**(不是轴),因此"单值信号与连续信号共用一条时间轴"
|
||||
(MF4 同一通道组)时不会误判。
|
||||
3. **特例:全部可见数据点都是单值信号、且时刻完全相同**(`FitSinglePointCluster()`):
|
||||
* 时间轴以该时刻**居中**、缩放到 **一大格 = 1s**
|
||||
(视界 = `kTimeTickTarget × 1s` = **10s**,此时 `NiceStep(10, 10)` 正好是 1.0
|
||||
——用户要求的"一大格 1s 的级别");
|
||||
* 每个网格的幅值区间以**能完整画出该网格内的全部数据点**为准
|
||||
(取值不同时 + 两侧半径余量并迭代;退化成单值时以**跨度 1 居中**,
|
||||
与"单值信号居中显示"的观感一致),并置 `autoRange = false`。
|
||||
* 该分支不再复位 `ZoomMode()`(用户要求"点适应视图后仍保持已启用的缩放模式")。
|
||||
|
||||
#### 三、初次自适应"没有发生"的根因(用户报告 ①)
|
||||
|
||||
- 没有任何信号被勾选时 `FitAll()` 也把 `Tab().ViewInitialized()` 置成了 **true**,
|
||||
于是随后勾选第一个信号时 `Application::SynchronizeView()` 认为"已经初始化过"⇒ **跳过自适应**,
|
||||
视界停在"无信号"那次的整段范围上(实测 MF4:42.386s 的视界,数据点被推到右边缘外)。
|
||||
- 修法:`FitAll()` **只有存在可见信号时**才置位 `ViewInitialized()`;
|
||||
`Application::SynchronizeView` 也不再分两步(不再先试 `FitSinglePointSignal`),直接 `FitAll()`。
|
||||
|
||||
#### 四、验证(离屏 `.preflight/check`,`SW_HIDE` + 只注入合成消息 + `BELOW_NORMAL`)
|
||||
|
||||
- `[A]` 无信号时连点 3 次"适应视图" ⇒ `ViewInitialized()` 仍为 false;再勾选单值信号 ⇒
|
||||
初次自适应发生(时间跨度 **10**、以该点居中、幅值跨度 **1** 居中、散点落在绘图区正中)。
|
||||
- `[B]` 时间边界:只有普通信号 ⇒ 视界**正好**等于数据范围;内部单值点 ⇒ 视界不变;
|
||||
左/右边界处的单值点 ⇒ 边界外扩,且**外扩量 = 半径 + 1px**(实测 0.013408s),散点完整可见。
|
||||
- `[C]` 幅值边界:单值点顶住某网格上边界 ⇒ 该网格上边界外扩、散点完整可见;
|
||||
反之"该网格只有一个取值"⇒ 保持自动自适应(区间 = 退化留白 `max(1,|v|×0.1)` 两侧展开)。
|
||||
- `[D]` 特例:两个网格、全部为同一时刻的单值点(一个网格内有 3 和 7 两个取值)⇒
|
||||
时间跨度 **10**、以该时刻居中、`NiceStep(10,10) == 1.0`、每个散点都在自己的网格内。
|
||||
- `[E]` 回归:普通信号的视界 = 数据范围、各网格保持 `autoRange`;
|
||||
§9.31 的"适应视图 ↔ Y 轴自适应互不干扰"(区间逐位相同)仍然成立。
|
||||
- `[F]` **真实文件** `TestData/MF4-Record.MF4`:`signals=124`,`WIN_Motor_StallCurrentVolBrk`
|
||||
确实只有 **1** 个有效点(t = 0.005492),用它 + `m_MOT_Current_DCom[0]`(**8475** 样点)复现用户场景:
|
||||
点「适应视图」后散点圆心离绘图区左边界 **16.5px**、半径 15px ⇒ 完整可见
|
||||
(修复前它正好落在边界上、只剩半个)。
|
||||
|
||||
---
|
||||
|
||||
## 10. 变更日志(本文件维护记录)
|
||||
|
||||
| 日期 | 设备/模型 | 摘要 |
|
||||
@@ -1833,6 +1907,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(**起源设备**) | **组名平滑悬浮 + 单点信号初始视图 + 光标吸附统一判定(自动点线同源)(详见 §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(**起源设备**) | **「适应视图」边界规则撤回重做:单值散点让出半径 + 单点簇视图(详见 §9.33)**。① **初次自适应没有发生**(用户报告):`FitAll()` 在**没有任何可见信号**时也把 `ViewInitialized()` 置 true ⇒ 随后勾选第一个信号时 `SynchronizeView` 认为已初始化、**跳过自适应**(实测 MF4 视界停在 42.386s,数据点被推到右边缘外);修法:只有存在可见信号才置位,并让初次自适应与工具栏「适应视图」**共用 `FitAll()` 一条路径**。② **单值散点被裁掉半个**(用户报告):旧规则"左右各留一小格"在"单值 + 连续信号"下既不准也不够(`WIN_Motor_StallCurrentVolBrk` 只有 1 个有效点,正好落在边界上);按用户要求**撤回旧策略**(删除 `FitSinglePointLanes`/`TimeGridCellSeconds`/`LaneValueGridCell`/`FitSinglePointSignal`),改为:**任何一条边界被单值散点顶住就让出该散点的半径(+1px)**,只在散点**会被裁切**时才扩展(内部散点不动视界),边界不是单值散点时边界就落在信号点上。③ **特例**:全部可见数据点都是单值信号且时刻相同 ⇒ 时间轴以该时刻居中、缩放到**一大格 = 1s**(视界 10s,`NiceStep(10,10)==1.0`),每个网格的幅值区间保证其数据点都能完整画出(退化成单值时跨度 1 居中)。验证:离屏 `.preflight/check` **62/62 通过**(连跑 3 次稳定;含**真实文件** `TestData/MF4-Record.MF4`:`WIN_Motor_StallCurrentVolBrk`(1 点) + `m_MOT_Current_DCom[0]`(8475 点) 复现用户场景,散点圆心离绘图区左边界 16.5px、半径 15px ⇒ 完整可见);**A/B** 回退四处规则后 **20 项如实失败**(含"散点正好压在绘图区右边缘"、"初次视界 42.386s");`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` 零警告、链接通过。 |
|
||||
|
||||
---
|
||||
@@ -2109,11 +2184,30 @@ GDI+ 抗锯齿逐段扫描这些**近竖直、满幅**的边,代价爆炸(
|
||||
67. ⚠️ **单点信号(`finiteCount == 1`)必须在命中测试 / 光晕 / 拖拽虚影里单独处理**:
|
||||
它没有折线可测距,`HitTestSignal` 要用 `SinglePointScreenPos()` + 容差 `max(常规容差, 点绘制半径)`;
|
||||
悬停时**不改鼠标形状**(`QueryCursor` 不查 `HitTestSignal`),只在按下拖拽后改(`IDC_SIZEALL`)。
|
||||
68. ⚠️ **多网格单点信号的「适应视图」规则**(用户 2026-10-15 明确给出):左/右边界 = 所有网格中
|
||||
68. ~~⚠️ **多网格单点信号的「适应视图」规则**(用户 2026-10-15 明确给出):左/右边界 = 所有网格中
|
||||
最左/最右数据点时间再向外偏**一个网格小格**的时间跨度;每个网格的 Y 按自身数据点自适应,
|
||||
若该网格内存在"时间一致但取值不一致"的多个单点 ⇒ 纵向上下各留**一个 Y 小格**;
|
||||
全部点在同一时刻 ⇒ 时间跨度 **1s** 居中于该时刻(与"首个单点信号初始视图"对齐);
|
||||
某网格只有一个取值 ⇒ 该网格 Y 跨度 **1** 居中。仅在**所有可见信号都是单点**时启用。
|
||||
某网格只有一个取值 ⇒ 该网格 Y 跨度 **1** 居中。仅在**所有可见信号都是单点**时启用。~~
|
||||
⚠️ **本规则已作废(§9.33,用户 2026-10-15 第五轮明确要求撤回重做)**:
|
||||
改为“边界处的单值散点让出**该散点的半径**”(而不是一小格),且不再要求“全部都是单点”
|
||||
才生效 —— 任何一条边界被单值散点顶住都要让位。现行规则见 69–71。
|
||||
69. ⚠️ **「适应视图」的边界规则(§9.33,用户 2026-10-15 第五轮重新定义)**:
|
||||
常规全局适应后,**若某条边界(时间轴左 / 右、每个网格幅值上 / 下)被一个单值信号的
|
||||
数据点顶住,则把该边界向外让出该散点的半径(+1px)**,使其完整显示在网格内;
|
||||
**只在散点会被裁切时才扩展**(内部散点不动视界);边界不是单值散点时边界就落在该
|
||||
信号点上(时间轴不留余量;幅值仍走 `PadValueRange` 的 6%,**没有**改动它)。
|
||||
判断依据必须是**信号自身的数据点**,不能用“轴的范围”——MF4 里单值信号与连续信号
|
||||
常共用同一通道组(同一条轴),用轴判断会误判。
|
||||
70. ⚠️ **特例:全部可见数据点都是单值信号且时刻完全相同**(`FitSinglePointCluster`):
|
||||
时间轴**以该时刻居中**、缩放到 **一大格 = 1s**(视界 = `kTimeTickTarget × 1s` = 10s,
|
||||
此时 `NiceStep(10,10) == 1.0`);每个网格的幅值区间以“能完整画出该网格内的全部数据点”
|
||||
为准(退化成单值时以**跨度 1 居中**)。旧规则“跨度 1s 居中”“各留一小格”**已作废**。
|
||||
71. ⚠️ **`FitAll()` 在没有任何可见信号时不得置位 `Tab().ViewInitialized()`**:
|
||||
否则用户先连点几次「适应视图」(此时无信号被勾选)再勾选第一个信号,
|
||||
`Application::SynchronizeView` 会**跳过初次自适应**,视界停在“无信号”那次的整段范围上,
|
||||
单值信号的数据点既不居中、还会有一半被裁在网格外(用户 2026-10-15 报告)。
|
||||
另外「适应视图」两条路径(初次自适应 / 工具栏按钮)必须**只有一条实现**(都走 `FitAll`)。
|
||||
|
||||
### 11.3 离屏验证方法学(本机偏好,强烈建议沿用)
|
||||
|
||||
|
||||
Reference in new issue
Block a user