组名平滑悬浮 + 单点信号初始视图 + 光标吸附统一判定(自动点线同源)
一、信号列表「组名悬浮」平滑化、且不再遮住下一个组名
* 悬浮位置改为 min(列表上边界, 本组最后一行下边界 − 组名高度):
- 本组还有行在列表里时钉在列表上边界;
- 本组最后一行开始滑出上边界时,组名跟着一起上移、随滚动条自然离界
(不再像旧实现那样突然消失);
- 「本组最后一行的下边界」恰好就是下一个分组行的自然位置,因此悬浮组名的下边界
永远不超过下一组名 —— 旧实现固定钉在列表上边界会把下一组名盖住,让别人以为
后面的信号属于上一组。
* 折叠的分组不再悬浮(折叠时本组“最后一行”就是它自己,悬浮位置 = 自然位置,
于是随滚动自然划过,不会停在最上方)。
* 底部边界线只在真的“浮”在其它行之上时才画,避免与正常绘制出现像素差。
二、单点信号的初始视图
* 新增 PlotView::FitSinglePointSignal():当前没有任何信号被勾选、而勾选的第一个信号
只有一个有效数据点时,把该数据点居中放置,时间跨度 = 1s、Y 轴跨度 = 1
(各为一个大格),并关闭该网格的整段自适应以免被覆盖。
Application::SynchronizeView 在首次勾选时先尝试它,失败才走 FitAll()。
* AutoRangeLaneToView 的退化区间(可见区间内只有一个取值)改用与 PadRange 一致的留白
max(1, |v|×0.1),不再产生“几乎为 0”的区间(那会让幅值网格线挤成一团、看不出数据点)。
* 需求作废:上一轮“2…16 个有效采样点默认改为点线 + 2px”已取消(放大自动点线已覆盖该场景);
只保留“只有一个有效采样点 → 点线 + 最大线宽”。
* SignalData::singlePointTime 升级为 sparseTimes/sparseValues(有效点 ≤ 1024 时在加载/插入
时缓存全部有效点),同时服务单点初始视图与光标吸附。
三、光标吸附的统一判定
* N = GridMinorCellCount():网格底衬一横排的小格数,与网格绘制用同一套
NiceStep + 五等分算出(因此不随缩放等级大幅变化)。
* SignalSnapEnabled():当且仅当某信号在当前一屏内的数据点个数 < N×4 时为它开启吸附;
判据随视界实时重算 ⇒ 疏密变化的信号在稀疏区自动吸附、移回稠密区自动自由移动。
额外要求视界内至少有一个可见数据点(否则“0 < N×4”会把数据全在视界之外的信号
误判为稀疏)。统计只在可见下标区间内进行且攒够阈值即早退,代价有界。
* SnapTimeToSample():判定只针对鼠标所在网格(同一个时间轴上不同网格结果可以不同);
同一网格内所有符合判定的信号的数据点都参与吸附比较;
吸附半径 = 一个网格小格的宽度,且不超过半个局部采样间距 ⇒ 数据点很稀疏
(一屏只有一两个点)时光标在点之间仍可自由移动(吸附是提示,不是卡住)。
* 删除旧的 SampleSpacingPixels(6px 下门限 + 半屏宽上门限)。
四、放大自动切点线改为与吸附判定完全同源
* UpdateAutoScatterStyles() 改用 SignalSnapEnabled:进入判定自动切「点线」,
不再满足(适应视图、或视界从稀疏区移到稠密区)自动恢复原样式;
自动期间用户手动改过则不恢复(尊重用户选择,判定放在恢复之前)。
* 删除旧的 AxisSparseInView(“采样间距 > 半屏宽”)。
五、验证(离屏 .preflight/check:SW_HIDE 窗口、不注入真实鼠标键盘、进程/线程 BELOW_NORMAL)
55/55 通过(连跑 3 次稳定):[A] 组名悬浮(钉住/平滑上移/整组滚过后换下一组/折叠组不悬浮/
悬浮下边界不超过下一组名)、[B] 单点初始视图(跨度 1s 与 1、居中、点线 + 最大线宽、
两信号时不再走单点路径)、[C] 吸附判定(满视图都关闭/稀疏窗口开启/无可见点不开启/
同一时间轴不同网格结果不同/同网格多信号的点都参与/稠密↔稀疏双向动态切换/
单点靠近吸附远离自由)、[D] 自动点线(同源判定/恢复/用户手动改过不覆盖)。
A/B:临时禁用「悬浮位置公式 / 单点初始视图 / 吸附判定」后重跑 ⇒ 14 项如实失败。
make release 零警告(用户正运行 Target.exe,链接用临时 -o 名字验证通过)。
六、规则同步
Documents/agent_mem.md 新增 §9.30、§11.2 第 58–61 条硬约束与变更日志。
This commit is contained in:
1 parent
9f6de3bba4
commit
57cc714c1d
8 files changed
+808
-230
No files matched your search
@@ -122,14 +122,40 @@ namespace jdi
|
||||
g.FillPolygon(&brush, arrow, 3);
|
||||
}
|
||||
|
||||
const float kCursorSnapMinPixels = 6.0f; /**< 光标吸附采样点的最小采样间隔(逻辑像素)。 */
|
||||
/**
|
||||
* @brief 「只有一个数据点」的信号的光标吸附半径(逻辑像素)。
|
||||
* @brief 光标吸附的**判定系数**:一屏内的数据点少于 `网格小格数 × 该系数` 时才开启吸附。
|
||||
*
|
||||
* 这类信号没有“采样间距”可依据,因此改用**半径判据**:光标距该点不超过该半径时吸附过去,
|
||||
* 其它区域自由移动(用户明确要求“吸附而不是卡住光标”,只是一个提示性响应)。
|
||||
* 用户 2026-10-15 明确定义:网格底衬一横排有 N 个小格(见 `GridMinorCellCount`),
|
||||
* **当且仅当**某信号在当前视界内的数据点个数 `< N × 4` 时为它开启光标吸附。
|
||||
* 该判据随视界实时重算 ⇒ 疏密变化的信号在稀疏区自动吸附、稠密区自动自由移动。
|
||||
*/
|
||||
const float kCursorSnapPointRadiusPixels = 8.0f;
|
||||
const int kSnapVisibleCellsPerPoint = 4;
|
||||
|
||||
/**
|
||||
* @brief 吸附半径(以**网格小格**为单位):一个数据点只用其**一个小格宽**内的移动来吸。
|
||||
*
|
||||
* 吸附半径 = 一个网格小格的宽度(与判定用同一套网格定义),且**不超过半个采样间距**
|
||||
* ——后者保证点很稀疏时(例如一屏只有一两个点)光标在点与点之间仍可自由移动,
|
||||
* 而不是被“死死吸住”(用户明确要求:吸附只是提示性响应)。
|
||||
*/
|
||||
const double kSnapRadiusCells = 1.0;
|
||||
|
||||
/**
|
||||
* @brief 一般信号查找“离光标最近的有效采样点”时向两侧探测的最大步数。
|
||||
*
|
||||
* 采样值里的 NaN 只出现在缺少数据的区段(例如多时间轴 CSV 的较短轴的尾部),
|
||||
* 因此从最近下标向外少量探测即可找到有效点;探测失败就不吸附(宁可自由移动)。
|
||||
*/
|
||||
const int kSnapSampleProbeSteps = 64;
|
||||
|
||||
/**
|
||||
* @brief 判定“可见数据点是否足够少”时允许扫描的最大样本数(相对阈值的倍数)。
|
||||
*
|
||||
* 判定只需知道“有效点是否少于阈值”,因此扫到阈值个有效点就早退;
|
||||
* 但连续 NaN 不会被计入,因此再给一个**有界**的扫描窗口防止极端情况下的长循环。
|
||||
*/
|
||||
const std::size_t kSnapScanWindowFactor = 64;
|
||||
|
||||
const float kLaneLabelPad = 6.0f; /**< 网格内侧标尺带的内边距。 */
|
||||
const float kLegendHeight = 19.0f; /**< 图例条目高度(逻辑像素)。 */
|
||||
const float kLegendPad = 5.0f; /**< 图例相对网格左上角的内缩。 */
|
||||
@@ -1346,6 +1372,104 @@ namespace jdi
|
||||
BuildLaneVisuals();
|
||||
}
|
||||
|
||||
bool PlotView::FitSinglePointSignal()
|
||||
{
|
||||
if (m_tab == nullptr || m_dataSet == nullptr || m_dataSet->Empty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 只在「当前没有任何信号被勾选,勾选的第一个信号只有一个有效数据点」时生效
|
||||
// (用户 2026-10-15 明确要求):此时整段自适应会让时间轴覆盖整条时间列、
|
||||
// Y 轴退化成几乎为 0 的区间,数据点既不在中心也看不出来。
|
||||
SignalRef only;
|
||||
int visibleCount = 0;
|
||||
for (const Lane& lane : m_tab->Lanes())
|
||||
{
|
||||
for (const SignalRef& ref : lane.signals)
|
||||
{
|
||||
if (!m_tab->IsVisible(ref))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
only = ref;
|
||||
if (++visibleCount > 1)
|
||||
{
|
||||
return false; // 多个可见信号:按常规的整段自适应处理
|
||||
}
|
||||
}
|
||||
}
|
||||
if (visibleCount != 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const SignalData* signal = m_dataSet->Resolve(only);
|
||||
const TimeAxis* axis = m_dataSet->Axis(only);
|
||||
if (signal == nullptr || axis == nullptr || signal->finiteCount != 1)
|
||||
{
|
||||
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))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 时间轴:跨度 = **一个大格(1s)**,数据点居中。
|
||||
const double halfTimeSpan = 0.5;
|
||||
SetTimeView(pointTime - halfTimeSpan, pointTime + halfTimeSpan);
|
||||
Tab().ViewInitialized() = true;
|
||||
Tab().LaneScroll() = 0.0f;
|
||||
Tab().ZoomMode() = PlotZoomMode::NONE;
|
||||
m_rubberRect = gd::RectF();
|
||||
m_dragMode = PlotDragMode::NONE;
|
||||
m_rubberAxis = PlotRubberAxis::UNDECIDED;
|
||||
SetHint(std::wstring());
|
||||
|
||||
// Y 轴:跨度 = **一个大格(1)**,数据点居中。
|
||||
// ⚠️ 必须同时关掉 `autoRange`(否则会被整段自适应覆盖)与 `fitYToSelection`
|
||||
//(选区自适应会按可见区间算出退化区间;全局 Y 轴自适应开启时那条路径也会生效)。
|
||||
const double halfValueSpan = 0.5;
|
||||
for (Lane& lane : m_tab->Lanes())
|
||||
{
|
||||
if (lane.signals.size() != 1 || !(lane.signals.front() == only))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
lane.autoRange = false;
|
||||
lane.fitYToSelection = false;
|
||||
lane.yMin = pointValue - halfValueSpan;
|
||||
lane.yMax = pointValue + halfValueSpan;
|
||||
}
|
||||
|
||||
InvalidateSceneCache();
|
||||
BuildLaneVisuals();
|
||||
return true;
|
||||
}
|
||||
|
||||
void PlotView::DisableCursor()
|
||||
{
|
||||
Tab().CursorMode() = PlotCursorMode::NONE;
|
||||
@@ -1684,134 +1808,342 @@ namespace jdi
|
||||
return m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].signals.size();
|
||||
}
|
||||
|
||||
bool PlotView::SampleSpacingPixels(const TimeAxis& axis, double time, double& pixels) const
|
||||
{ pixels = 0.0;
|
||||
int PlotView::GridMinorCellCount() const
|
||||
{
|
||||
// 与实际画出来的网格用同一套算法(`PaintLane`/`PaintTimeAxis` 的 `NiceStep` + 五等分),
|
||||
// 因此这里得到的“一横排小格数”就是用户看到的格子数。
|
||||
if (m_plotRect.Width <= 1.0f)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
const double span = Tab().ViewMax() - Tab().ViewMin();
|
||||
if (!(span > 0.0) || !std::isfinite(span))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
const double majorStep = NiceStep(span, kTimeTickTarget);
|
||||
const double minorStep = majorStep / static_cast<double>(kMinorDivisions);
|
||||
if (!(minorStep > 0.0) || !std::isfinite(minorStep))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
const double cells = span / minorStep;
|
||||
if (!std::isfinite(cells) || cells <= 0.0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
// 视界两端各可能只覆盖半格,四舍五入到最近的整格数即可(判据只用到它 × 4)。
|
||||
return std::max(1, static_cast<int>(std::llround(cells)));
|
||||
}
|
||||
|
||||
const std::vector<double>& samples = axis.time;
|
||||
if (samples.size() < 2 || m_plotRect.Width <= 1.0f)
|
||||
bool PlotView::SignalSnapEnabled(const TimeAxis& axis, const SignalData& signal) const
|
||||
{
|
||||
const int cells = GridMinorCellCount();
|
||||
if (cells <= 0 || signal.finiteCount == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
const std::size_t threshold =
|
||||
static_cast<std::size_t>(cells) * static_cast<std::size_t>(kSnapVisibleCellsPerPoint);
|
||||
if (threshold == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const double viewSpan = Tab().ViewMax() - Tab().ViewMin();
|
||||
if (!(viewSpan > 0.0))
|
||||
const std::size_t count = std::min(axis.time.size(), signal.values.size());
|
||||
if (count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
std::size_t begin = 0;
|
||||
std::size_t end = 0;
|
||||
VisibleIndexRange(axis, count, begin, end);
|
||||
if (end <= begin)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// ⚠️ 视界内**至少要有一个可见数据点**:一个数据点都不在屏幕上的信号既没有可吸附的
|
||||
// 对象、也没有可提示的内容。少了这一条,“数据点全都落在视界之外”的信号会被判成
|
||||
// “稀疏”(0 < N×4)而被自动切成「点线」,属于无意义的状态抖动。
|
||||
if (signal.finiteCount < threshold)
|
||||
{
|
||||
// 有效点总数本来就不足阈值 ⇒ 只需确认视界内确实有点(稀疏点已缓存时二分判定)。
|
||||
return HasVisibleSample(axis, signal, begin, end);
|
||||
}
|
||||
|
||||
// 一般信号:统计**当前视界内**的有效点数量(攒够阈值立刻早退,代价有界)。
|
||||
// 连续 NaN 不计入有效点,因此再给一个**有界**的扫描窗口,防止极端情况下长循环。
|
||||
const std::size_t window =
|
||||
std::min(std::min(end, signal.values.size()), begin + threshold * kSnapScanWindowFactor + 8u);
|
||||
std::size_t seen = 0;
|
||||
for (std::size_t index = begin; index < window; ++index)
|
||||
{
|
||||
if (!std::isfinite(signal.values[index]))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!std::isfinite(axis.time[index]) || axis.time[index] < Tab().ViewMin() ||
|
||||
axis.time[index] > Tab().ViewMax())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (++seen >= threshold)
|
||||
{
|
||||
return false; // 一屏内的数据点已经足够密:保持自由移动
|
||||
}
|
||||
}
|
||||
return seen > 0;
|
||||
}
|
||||
|
||||
bool PlotView::HasVisibleSample(const TimeAxis& axis, const SignalData& signal, std::size_t begin,
|
||||
std::size_t end) const
|
||||
{
|
||||
const double viewMin = Tab().ViewMin();
|
||||
const double viewMax = Tab().ViewMax();
|
||||
|
||||
// 稀疏信号(有效点已缓存且升序):二分即可。
|
||||
if (!signal.sparseTimes.empty())
|
||||
{
|
||||
const std::vector<double>& times = signal.sparseTimes;
|
||||
const std::vector<double>::const_iterator it = std::lower_bound(times.begin(), times.end(), viewMin);
|
||||
return it != times.end() && *it <= viewMax;
|
||||
}
|
||||
|
||||
end = std::min(end, std::min(axis.time.size(), signal.values.size()));
|
||||
for (std::size_t index = begin; index < end; ++index)
|
||||
{
|
||||
const double time = axis.time[index];
|
||||
if (!std::isfinite(signal.values[index]) || !std::isfinite(time) || time < viewMin ||
|
||||
time > viewMax)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool PlotView::NearestSnapSample(const TimeAxis& axis, const SignalData& signal, double time,
|
||||
double& candidate) const
|
||||
{
|
||||
const double viewMin = Tab().ViewMin();
|
||||
const double viewMax = Tab().ViewMax();
|
||||
|
||||
// 稀疏信号:有效点已在加载时缓存(按时间升序)⇒ 二分取最近的一个。
|
||||
if (!signal.sparseTimes.empty())
|
||||
{
|
||||
const std::vector<double>& times = signal.sparseTimes;
|
||||
const std::vector<double>::const_iterator it = std::lower_bound(times.begin(), times.end(), time);
|
||||
bool found = false;
|
||||
double bestDistance = std::numeric_limits<double>::infinity();
|
||||
for (int side = 0; side < 2; ++side)
|
||||
{
|
||||
// side 0 = 第一个 >= time 的点;side 1 = 它前面的那个点。
|
||||
if (side == 1 && it == times.begin())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
const std::vector<double>::const_iterator pick = (side == 0) ? it : (it - 1);
|
||||
if (pick == times.end())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
const double value = *pick;
|
||||
if (!std::isfinite(value) || value < viewMin || value > viewMax)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
const double distance = std::fabs(value - time);
|
||||
if (distance < bestDistance)
|
||||
{
|
||||
bestDistance = distance;
|
||||
candidate = value;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
// 一般信号:取下标最近的采样点;它若无效(NaN)则向两侧**有界**探测
|
||||
//(NaN 只出现在缺少数据的区段,通常几步内就能找到有效点;找不到就不吸附)。
|
||||
const std::size_t count = std::min(axis.time.size(), signal.values.size());
|
||||
if (count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
const int center = FindNearestSample(axis, time);
|
||||
if (center < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
const int lastIndex = static_cast<int>(count) - 1;
|
||||
for (int step = 0; step <= kSnapSampleProbeSteps; ++step)
|
||||
{
|
||||
for (int direction = 0; direction < 2; ++direction)
|
||||
{
|
||||
if (step == 0 && direction == 1)
|
||||
{
|
||||
continue; // 中心点只查一次
|
||||
}
|
||||
const int probe = (direction == 0) ? (center - step) : (center + step);
|
||||
if (probe < 0 || probe > lastIndex)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
const double value = signal.values[static_cast<std::size_t>(probe)];
|
||||
const double sampleTime = axis.time[static_cast<std::size_t>(probe)];
|
||||
if (!std::isfinite(value) || !std::isfinite(sampleTime))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// 只接受**可见区间内**的采样点:视界之外的采样点不是合法的落点
|
||||
//(否则吸附会把光标一把甩到视界边缘并钉住)。
|
||||
if (sampleTime < viewMin || sampleTime > viewMax)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
candidate = sampleTime;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void PlotView::LocalSampleSpacing(const TimeAxis& axis, const SignalData& signal, double time,
|
||||
double& spacing) const
|
||||
{
|
||||
spacing = 0.0;
|
||||
if (signal.sparseTimes.size() >= 2)
|
||||
{
|
||||
const std::vector<double>& times = signal.sparseTimes;
|
||||
const std::size_t found =
|
||||
static_cast<std::size_t>(std::lower_bound(times.begin(), times.end(), time) - times.begin());
|
||||
const std::size_t left = (found > 0) ? (found - 1) : 0;
|
||||
const std::size_t right = std::min(times.size() - 1, left + 1);
|
||||
if (right > left)
|
||||
{
|
||||
const double gap = times[right] - times[left];
|
||||
if (gap > 0.0 && std::isfinite(gap))
|
||||
{
|
||||
spacing = gap;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ⚠️ 有效点只有一个(或没有)时,**数据点之间的间距没有定义**:此时不能拿时间轴的
|
||||
// 采样间隔充数(NaN 填充的轴上那可能是 1ms,而数据点其实孤零零一个)——
|
||||
// 否则吸附半径会被压到极小、单点信号就永远吸不住。返回 0 = 不限制半径。
|
||||
if (signal.finiteCount <= 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const std::size_t count = std::min(axis.time.size(), signal.values.size());
|
||||
if (count < 2)
|
||||
{
|
||||
return;
|
||||
}
|
||||
const int index = FindNearestSample(axis, time);
|
||||
if (index < 0)
|
||||
{
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
double spacing = 0.0;
|
||||
if (index > 0)
|
||||
const int first = (index > 0) ? (index - 1) : 0;
|
||||
const int second = std::min(static_cast<int>(count) - 1, first + 1);
|
||||
if (second <= first)
|
||||
{
|
||||
spacing = samples[static_cast<std::size_t>(index)] - samples[static_cast<std::size_t>(index) - 1];
|
||||
return;
|
||||
}
|
||||
else
|
||||
// 只接受两端都是**有效**采样点的区间(NaN 段的“间距”没有意义)。
|
||||
if (!std::isfinite(signal.values[static_cast<std::size_t>(first)]) ||
|
||||
!std::isfinite(signal.values[static_cast<std::size_t>(second)]))
|
||||
{
|
||||
spacing = samples[1] - samples[0];
|
||||
return;
|
||||
}
|
||||
// 时间轴非单调时前后差值可能为负,此时不做吸附。
|
||||
if (!(spacing > 0.0) || !std::isfinite(spacing))
|
||||
const double gap = axis.time[static_cast<std::size_t>(second)] -
|
||||
axis.time[static_cast<std::size_t>(first)];
|
||||
if (gap > 0.0 && std::isfinite(gap))
|
||||
{
|
||||
return false;
|
||||
spacing = gap;
|
||||
}
|
||||
|
||||
pixels = spacing / viewSpan * static_cast<double>(m_plotRect.Width);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool PlotView::SnapTimeToSample(double time, double& snapped) const
|
||||
{
|
||||
if (m_dataSet == nullptr)
|
||||
if (m_dataSet == nullptr || m_tab == nullptr)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const double minPixels = static_cast<double>(kCursorSnapMinPixels) * static_cast<double>(m_scale);
|
||||
// 吸附的上门限:视界内至少要放得下 2 个采样点。
|
||||
// 视界比采样间隔还窄时(选区放大很容易做到),"最近采样点"落在视界之外或半个视界之外,
|
||||
// 吸附会把光标一把甩到视界边缘并钉住 —— 用户报告的"拖光标直接飞到视界外"。
|
||||
const double maxPixels = 0.5 * static_cast<double>(m_plotRect.Width);
|
||||
const double viewMin = Tab().ViewMin();
|
||||
const double viewMax = Tab().ViewMax();
|
||||
// 吸附判定**只针对鼠标所在的网格**(用户明确要求):
|
||||
// 一个网格内的数据已达“稀疏”标准、另一个没有时,光标只在前者上吸附,
|
||||
// 在后者上仍然完全自由移动。
|
||||
const int laneIndex = LaneAtY(m_mouseY);
|
||||
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const int cells = GridMinorCellCount();
|
||||
const double plotWidth = static_cast<double>(m_plotRect.Width);
|
||||
const double viewSpan = Tab().ViewMax() - Tab().ViewMin();
|
||||
if (cells <= 0 || plotWidth <= 1.0 || !(viewSpan > 0.0))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// 吸附半径 = **一个网格小格的宽度**(与判定用同一套网格定义推出),
|
||||
// 且**不超过半个采样间距** —— 后者保证数据点很稀疏时(一屏只有一两个点)
|
||||
// 光标在点与点之间仍可自由移动,而不是被“死死吸住”
|
||||
//(用户明确要求:吸附只是提示性响应)。
|
||||
const double radiusPixels = kSnapRadiusCells * plotWidth / static_cast<double>(cells);
|
||||
const double radiusTime = radiusPixels / plotWidth * viewSpan;
|
||||
|
||||
bool found = false;
|
||||
double bestDistance = std::numeric_limits<double>::infinity();
|
||||
double bestTime = 0.0;
|
||||
|
||||
// ---- 只有一个有效采样点的信号:**靠近才吸附**(半径判据)----
|
||||
// 这类信号根本算不出“采样间距”(间距无定义),旧逻辑会直接跳过 ⇒ 光标永远无法
|
||||
// 准确指到那个点(用户报告“无法准确指向数据点”)。
|
||||
// 现在改为:光标在半径内才吸附过去,其它区域完全自由移动(**吸附而非卡住**,
|
||||
// 只是为用户移动光标时给出提示性响应)。
|
||||
const double viewSpan = viewMax - viewMin;
|
||||
const double pointSnapRadiusTime =
|
||||
(m_plotRect.Width > 1.0f)
|
||||
? (static_cast<double>(kCursorSnapPointRadiusPixels) * static_cast<double>(m_scale) /
|
||||
static_cast<double>(m_plotRect.Width) * viewSpan)
|
||||
: 0.0;
|
||||
if (pointSnapRadiusTime > 0.0)
|
||||
// 同一个网格内可能存在多条信号:**所有**符合吸附判定的数据点都参与吸附。
|
||||
for (const SignalRef& ref : m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].signals)
|
||||
{
|
||||
for (const SignalGroup& group : m_dataSet->Groups())
|
||||
if (!m_tab->IsVisible(ref))
|
||||
{
|
||||
for (const SignalData& signal : group.signals)
|
||||
{
|
||||
const double only = signal.singlePointTime;
|
||||
if (!std::isfinite(only) || only < viewMin || only > viewMax)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
const double distance = std::fabs(only - time);
|
||||
if (distance <= pointSnapRadiusTime && distance < bestDistance)
|
||||
{
|
||||
bestDistance = distance;
|
||||
bestTime = only;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
for (const SignalGroup& group : m_dataSet->Groups())
|
||||
{
|
||||
// 只有采样点在屏幕上足够稀疏时才吸附,否则缩小视图后光标会“跳格”。
|
||||
// 一个分组可能有多条时间轴(多 `time` 列 CSV / MF4 多通道组),逐条尝试。
|
||||
for (const TimeAxis& groupAxis : group.axes)
|
||||
const SignalData* signal = m_dataSet->Resolve(ref);
|
||||
const TimeAxis* axis = m_dataSet->Axis(ref);
|
||||
if (signal == nullptr || axis == nullptr)
|
||||
{
|
||||
double pixels = 0.0;
|
||||
if (!SampleSpacingPixels(groupAxis, time, pixels) || pixels < minPixels ||
|
||||
pixels > maxPixels)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// 只有“一屏内数据点少于 N×4”的信号参与吸附;判据随视界实时重算,
|
||||
// 因此疏密变化的信号在稀疏区自动吸附、移到稠密区自动恢复自由移动。
|
||||
if (!SignalSnapEnabled(*axis, *signal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
double candidate = 0.0;
|
||||
if (!NearestSnapSample(*axis, *signal, time, candidate))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
const int index = FindNearestSample(groupAxis, time);
|
||||
if (index < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
const double candidate = groupAxis.time[static_cast<std::size_t>(index)];
|
||||
if (!std::isfinite(candidate))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// 只吸附**当前可见区间内**的采样点:视界之外的采样点不是合法的落点。
|
||||
if (candidate < viewMin || candidate > viewMax)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// 吸附半径再受“半个局部采样间距”限制(点很稀疏时不被吸死)。
|
||||
double limitTime = radiusTime;
|
||||
double spacingTime = 0.0;
|
||||
LocalSampleSpacing(*axis, *signal, candidate, spacingTime);
|
||||
if (spacingTime > 0.0)
|
||||
{
|
||||
limitTime = std::min(limitTime, spacingTime * 0.5);
|
||||
}
|
||||
|
||||
const double distance = std::fabs(candidate - time);
|
||||
if (distance < bestDistance)
|
||||
{
|
||||
bestDistance = distance;
|
||||
bestTime = candidate;
|
||||
found = true;
|
||||
}
|
||||
const double distance = std::fabs(candidate - time);
|
||||
if (distance <= limitTime && distance < bestDistance)
|
||||
{
|
||||
bestDistance = distance;
|
||||
bestTime = candidate;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4015,9 +4347,13 @@ namespace jdi
|
||||
}
|
||||
else
|
||||
{
|
||||
const double pad = std::max(1.0e-9, std::fabs(low) * 0.05);
|
||||
low -= pad;
|
||||
high += pad;
|
||||
// ⚠️ 可见区间内只有一个取值(单点信号 / 恒定值):给一个**可读**的跨度,
|
||||
// 而不是几乎为 0 的退化区间 —— 退化区间会让幅值网格线挤成一团、
|
||||
// 数据点也看不出位置(用户报告“单值信号的全局自适应不生效”)。
|
||||
// 这里的留白与 `TabConfig::ComputeLaneRange` 的 `PadRange` 完全一致。
|
||||
const double magnitude = std::max(1.0, std::fabs(low) * 0.1);
|
||||
low -= magnitude;
|
||||
high += magnitude;
|
||||
}
|
||||
|
||||
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
|
||||
@@ -4077,24 +4413,6 @@ namespace jdi
|
||||
ApplyAllAutoFitToSelectionNow();
|
||||
}
|
||||
|
||||
bool PlotView::AxisSparseInView(const TimeAxis& axis) const
|
||||
{
|
||||
if (m_plotRect.Width <= 1.0f)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// 单个采样点的时间轴不算“放大到稀疏”(它本来就处于最稀疏状态,默认样式已是点线);
|
||||
// 若把这种情况也算进来,用户手动改成线段会被自动逻辑立刻改回去。
|
||||
double pixels = 0.0;
|
||||
const double center = (Tab().ViewMin() + Tab().ViewMax()) * 0.5;
|
||||
if (!SampleSpacingPixels(axis, center, pixels))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// 与光标吸附的上门限同源:采样点间距超过半个绘图区宽度时,视界内只剩一两个采样点。
|
||||
return pixels > 0.5 * static_cast<double>(m_plotRect.Width);
|
||||
}
|
||||
|
||||
bool PlotView::UpdateAutoScatterStyles()
|
||||
{
|
||||
if (m_dataSetWritable == nullptr || m_tab == nullptr)
|
||||
@@ -4114,8 +4432,9 @@ namespace jdi
|
||||
continue;
|
||||
}
|
||||
const TimeAxis* axis = m_dataSetWritable->Axis(ref);
|
||||
const SignalData* data = m_dataSetWritable->Resolve(ref);
|
||||
SignalStyle* style = m_dataSetWritable->Style(ref);
|
||||
if (axis == nullptr || style == nullptr)
|
||||
if (axis == nullptr || data == nullptr || style == nullptr)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -4123,14 +4442,17 @@ namespace jdi
|
||||
// ⚠️ **先**判定“用户是否手动改过绘制方式”:本机制只会写入「点线」,
|
||||
// 所以自动启用期间 mode 不等于「点线」只可能是用户改的。
|
||||
// 这一步必须在下面的分支之前做 —— 用户改完样式可能**紧接着就缩出去**
|
||||
// (例如直接点适应视图),那次调用的视界已经不稀疏,若等到那时才判定,
|
||||
// (例如直接点适应视图),那次调用的视界已经不再满足条件,若等到那时才判定,
|
||||
// 恢复逻辑会先把用户的修改覆盖掉(用户明确要求不能这样)。
|
||||
if (style->autoScatterActive && style->mode != DrawMode::LINE_SCATTER)
|
||||
{
|
||||
style->autoScatterUserOverride = true;
|
||||
}
|
||||
|
||||
if (AxisSparseInView(*axis))
|
||||
// 判定与**光标吸附完全同源**(用户 2026-10-15 明确要求):
|
||||
// 只有“一屏内数据点少于 N×4”的信号才自动切「点线」,
|
||||
// 不再满足时(适应视图、或把视界移到信号的稠密区)自动恢复原样式。
|
||||
if (SignalSnapEnabled(*axis, *data))
|
||||
{
|
||||
if (!style->autoScatterActive)
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user