- Lane::ResampleItem + resample/resampleItems/resamplePanel/resampleScroll, 并在 LaneCarry / SplitLane / PruneLaneSignalItems / RemapAfterSignalInsert|Removal 同步 - 面板:可滚动分组列表(信号色条目栏 + 复选框 + 展开三角),展开后 4 行: 最大/最小数据间隔(只读,勾选时一次性缓存,绘制不扫描采样)+ 重采样间隔/频率(输入) - 输入框联动:改间隔同步频率,改频率同步间隔(输入单位分别为秒 / Hz) - 「构建为新信号」:DataSet::AppendGroupAxis 追加新时间轴 + InsertDerivedSignal, 采样值先按公式/系数/绝对值/镜像/Y偏移变换再等间隔线性插值,时间轴偏移不烘值 - 新信号名 <原信号> (重采样 <自适应单位频率>);Utf8Util::FormatFrequency 为唯一实现 - 非单调轴 / 点太少 / 间隔过大 / 点数超 2000 万 ⇒ 拒绝构建并给出自绘提示 - 顺带加入 FormulaEval(公式编译/求值,供 ApplyDrawTransform 与后续 UI 使用)
3858 lines
132 KiB
C++
3858 lines
132 KiB
C++
// PlotView 实现分片 —— 本文件只包含 PlotView 的**成员函数定义**(纯代码搬移,行为与拆分前完全一致)。
|
||
//
|
||
// 共用的常量与工具函数在 PlotViewInternal.hpp(命名空间 `pvdetail`,全部 inline)。
|
||
// 四个分片的职责划分(按功能):
|
||
// core 视图核心:构造/析构、上下文绑定、布局、坐标映射、视界与平移、网格结构与归属、
|
||
// 选区/网格划分、拖动取消
|
||
// paint 渲染与像素缓存:全部绘制(Paint*/Render*)、场景缓存、装饰缓存、每网格图层、
|
||
// 宽基底与滚动补条、并行图层重建
|
||
// interaction 交互与编辑:命中测试、鼠标与键盘处理、光标形状、内联输入框、浮动面板层序与命中
|
||
// features 视图特性与派生数据:光标状态与读数、提示浮层、缩放模式、自适应、光标吸附、
|
||
// 单点信号、派生曲线线程池、边沿统计、实验性功能(均值滤波 / 弱峰调整)参数
|
||
#include "PlotViewInternal.hpp"
|
||
|
||
namespace jdi
|
||
{
|
||
|
||
bool PlotView::CursorEnabled() const
|
||
{
|
||
return Tab().CursorEnabled();
|
||
}
|
||
|
||
double PlotView::CursorTime() const
|
||
{
|
||
return Tab().CursorTime();
|
||
}
|
||
|
||
PlotCursorMode PlotView::CursorMode() const
|
||
{
|
||
return Tab().CursorMode();
|
||
}
|
||
|
||
void PlotView::ZoomTimeAt(double factor, double anchorTime)
|
||
{
|
||
if (m_dataSet == nullptr || m_dataSet->Empty() || !(factor > 0.0) || !std::isfinite(anchorTime))
|
||
{
|
||
return;
|
||
}
|
||
|
||
double dataMin = 0.0;
|
||
double dataMax = 1.0;
|
||
TimeBounds(dataMin, dataMax);
|
||
const double dataSpan = std::max(dataMax - dataMin, kMinTimeSpan);
|
||
|
||
const double span = Tab().ViewMax() - Tab().ViewMin();
|
||
if (!(span > 0.0))
|
||
{
|
||
return;
|
||
}
|
||
// 下限与选区放大共用同一个来源(见 `MinimumTimeSpan`),
|
||
// 否则“滚轮缩放有限制、选区放大没有”会让视界退化到不可用。
|
||
const double minimum = MinimumTimeSpan(Tab().ViewMin(), Tab().ViewMax());
|
||
const double newSpan = ClampDouble(span / factor, minimum, dataSpan * 100.0);
|
||
|
||
const double ratio = (anchorTime - Tab().ViewMin()) / span;
|
||
const double low = anchorTime - ratio * newSpan;
|
||
SetTimeView(low, low + newSpan);
|
||
}
|
||
|
||
void PlotView::ZoomTimeStep(double factor)
|
||
{
|
||
const double anchor = (Tab().ViewMin() + Tab().ViewMax()) * 0.5;
|
||
ZoomTimeAt(factor, anchor);
|
||
}
|
||
|
||
void PlotView::FitAll()
|
||
{
|
||
if (m_dataSet == nullptr || m_dataSet->Empty())
|
||
{
|
||
SetTimeView(0.0, 1.0);
|
||
return;
|
||
}
|
||
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);
|
||
if (!(high > low))
|
||
{
|
||
const double padding = std::max(1.0e-6, std::fabs(low) * 0.01);
|
||
low -= padding;
|
||
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);
|
||
// ⚠️ **只有真的存在可见信号时才置位 `ViewInitialized()`**:没有任何信号被勾选时
|
||
//(用户先连点几次「适应视图」)必须保持"未初始化",否则随后勾选第一个信号时
|
||
// `Application::SynchronizeView` 会**跳过初次自适应** —— 数据点既不居中,
|
||
// 还可能有一半被裁在网格外(用户 2026-10-15 报告)。
|
||
if (visibleCount > 0)
|
||
{
|
||
Tab().ViewInitialized() = true;
|
||
}
|
||
// ⚠️ **不再复位网格纵向滚动(`Tab().LaneScroll()`)**:用户 2026-10-16 明确要求
|
||
// “适应视图不要影响滚动条” —— 加载了大量网格并向下翻动后点「适应视图」,
|
||
// 旧实现会把视界瞬移回最上方(用户报告)。适应视图只负责时间轴/幅值轴。
|
||
m_laneScrollAnimActive = false;
|
||
// 只放下进行中的选区与拖拽,**不改变「选区放大」等缩放模式的启用状态**
|
||
// (用户明确要求:点适应视图后仍保持已启用的缩放模式)。
|
||
m_rubberRect = gd::RectF();
|
||
m_dragMode = PlotDragMode::NONE;
|
||
m_dragSignal = SignalRef();
|
||
m_dragTargetLane = -1;
|
||
m_hoverSignal = SignalRef();
|
||
SetHint(Tab().ZoomMode() == PlotZoomMode::NONE
|
||
? std::wstring()
|
||
: L"横向或纵向拖动以放大选择区域,ESC退出选区模式");
|
||
|
||
// ---- 逐网格的幅值轴 ----
|
||
// 保留既有语义:开启了「选区 Y 轴自适应」的网格按新的(整段)时间视图重新匹配,
|
||
// 其余网格回到整段自适应;随后再为"被单值散点顶住的边界"让出余量。
|
||
const float laneHeight = FitLaneHeight();
|
||
for (std::size_t index = 0; index < m_tab->Lanes().size(); ++index)
|
||
{
|
||
const int laneIndex = static_cast<int>(index);
|
||
if (m_tab->Lanes()[index].fitYToSelection)
|
||
{
|
||
AutoRangeLaneToView(laneIndex);
|
||
}
|
||
else
|
||
{
|
||
m_tab->Lanes()[index].autoRange = true;
|
||
}
|
||
ApplySinglePointValueMargins(laneIndex, perLane[index], laneHeight);
|
||
}
|
||
// 适应视图已回到初始状态,这里不再移动光标(光标允许停留在可视区外)。
|
||
BuildLaneVisuals();
|
||
}
|
||
|
||
std::size_t PlotView::CollectFitContributions(std::vector<std::vector<FitContribution>>& perLane) const
|
||
{
|
||
perLane.clear();
|
||
if (m_tab == nullptr || m_dataSet == nullptr)
|
||
{
|
||
return 0;
|
||
}
|
||
perLane.assign(m_tab->Lanes().size(), std::vector<FitContribution>());
|
||
|
||
std::size_t total = 0;
|
||
for (std::size_t laneIndex = 0; laneIndex < m_tab->Lanes().size(); ++laneIndex)
|
||
{
|
||
for (const SignalRef& ref : m_tab->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 || data->finiteCount == 0)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
// ⚠️ 该信号自己的时间轴偏移:采样画在 `t + offsetTime` 处,
|
||
// 因此"这段数据在屏幕上占用的时间范围"与"单值数据点的时刻"都要加上它。
|
||
// 少了这一项,「适应视图」在信号被时间偏移后仍按原始时间范围取景
|
||
// (用户 2026-10-29 报告的缺陷)。
|
||
const SignalStyle* style = m_dataSet->Style(ref);
|
||
const double timeShift = (style != nullptr) ? style->offsetTime : 0.0;
|
||
|
||
FitContribution item;
|
||
item.ref = ref;
|
||
// 与 `ComputeVisibleTimeBounds` 同源:时间边界用**时间轴**的范围
|
||
//(一个分组的多条信号可能共用一条轴,轴范围才是"这一段数据"的边界)。
|
||
item.minTime = axis->minTime + timeShift;
|
||
item.maxTime = axis->maxTime + timeShift;
|
||
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
|
||
{
|
||
// 同样是**显示时刻**(`time + offsetTime`):视界的居中与边界让位都以它为准。
|
||
item.pointTime = time + timeShift;
|
||
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 (newLow == low && newHigh == high)
|
||
{
|
||
return; // 散点本来就完整可见:不改动区间(保持自动自适应的语义)
|
||
}
|
||
|
||
// ⚠️ 必须**同时**关掉 `autoRange`:否则 `ComputeLaneRange` 会忽略下面写入的区间。
|
||
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
|
||
lane.autoRange = false;
|
||
lane.yMin = newLow;
|
||
lane.yMax = newHigh;
|
||
}
|
||
|
||
void PlotView::FitSinglePointCluster(const std::vector<std::vector<FitContribution>>& perLane,
|
||
double sharedTime)
|
||
{
|
||
// 时间轴:该时刻居中、**一大格 = 1s**(视界 = 大格数 × 1s)。
|
||
//
|
||
// 幅值轴与其余场景**共用同一段逻辑**(见函数末尾):`fitYToSelection` 的网格走
|
||
// `AutoRangeLaneToView`、其余走整段自适应(`ComputeLaneRange`),两者都只由
|
||
// `PadValueRange` 提供留白。这样「适应视图」与「Y 轴自适应」互为幂等 ——
|
||
// 同一视界下反复切换两者不会改变区间,也不会关掉 Y 轴自适应开关(用户 2026-10-16)。
|
||
const double span =
|
||
static_cast<double>(kTimeTickTarget) * kSinglePointClusterSecondsPerCell;
|
||
SetTimeView(sharedTime - span * 0.5, sharedTime + span * 0.5);
|
||
Tab().ViewInitialized() = true;
|
||
// ⚠️ 同 `FitAll()`:**不复位网格纵向滚动**(用户 2026-10-16 要求)。
|
||
m_laneScrollAnimActive = false;
|
||
// ⚠️ **不要**在这里复位 `ZoomMode()`:用户明确要求"点适应视图后仍保持已启用的
|
||
// 缩放模式"(`FitAll` 的常规路径同样只更新提示、不动缩放模式)。
|
||
m_rubberRect = gd::RectF();
|
||
m_dragMode = PlotDragMode::NONE;
|
||
m_dragSignal = SignalRef();
|
||
m_dragTargetLane = -1;
|
||
m_hoverSignal = SignalRef();
|
||
m_rubberAxis = PlotRubberAxis::UNDECIDED;
|
||
SetHint(Tab().ZoomMode() == PlotZoomMode::NONE
|
||
? std::wstring()
|
||
: L"横向或纵向拖动以放大选择区域,ESC退出选区模式");
|
||
|
||
// ---- 逐网格的幅值轴 ----
|
||
// ⚠️ **必须与 `FitAll()` 的常规路径、以及「Y 轴自适应」(`AutoRangeLaneToView`)同源**:
|
||
// 旧实现在这条特例里按“散点半径留白 + 跨度 1”另算一套区间,而 Y 轴自适应走
|
||
// `PadValueRange`,于是对**同一个视界**两者会算出不同的幅值范围
|
||
//(用户 2026-10-16 报告:单数据点信号上「适应视图」与「Y 轴自适应」的范围不一致);
|
||
// 并且旧实现还把 `fitYToSelection` 置回 false —— 原本开着「Y 轴自适应」时点一下
|
||
// 「适应视图」会把这个开关**悄悄关掉**。
|
||
// 现在两者共用同一段逻辑:留白只来自 `PadValueRange`,且**不改动** Y 轴自适应开关。
|
||
const float laneHeight = FitLaneHeight();
|
||
for (std::size_t index = 0; index < perLane.size() && index < m_tab->Lanes().size(); ++index)
|
||
{
|
||
const int laneIndex = static_cast<int>(index);
|
||
if (m_tab->Lanes()[index].fitYToSelection)
|
||
{
|
||
// 已开启「选区 Y 轴自适应」的网格按当前(刚设好的)时间视图重新匹配 ——
|
||
// 与工具栏开关打开时的算法完全一致,因此两种操作互为幂等。
|
||
AutoRangeLaneToView(laneIndex);
|
||
}
|
||
else
|
||
{
|
||
m_tab->Lanes()[index].autoRange = true;
|
||
}
|
||
// 补齐“被单值散点顶住的边界”(散点本来就完整可见时保持不变)。
|
||
ApplySinglePointValueMargins(laneIndex, perLane[index], laneHeight);
|
||
}
|
||
|
||
InvalidateSceneCache();
|
||
BuildLaneVisuals();
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
void PlotView::DisableCursor()
|
||
{
|
||
Tab().CursorMode() = PlotCursorMode::NONE;
|
||
m_cursorHover = false;
|
||
m_hoverCursor = 0;
|
||
if (m_editTarget == PlotEditTarget::CURSOR_TIME || m_editTarget == PlotEditTarget::CURSOR_SPAN)
|
||
{
|
||
EndEdit(false);
|
||
}
|
||
if (m_dragMode == PlotDragMode::CURSOR_MOVE || m_dragMode == PlotDragMode::CURSOR_SPAN_MOVE)
|
||
{
|
||
m_dragMode = PlotDragMode::NONE;
|
||
}
|
||
}
|
||
|
||
bool PlotView::CycleCursorMode()
|
||
{
|
||
// 三态循环:无 → 单光标 → 双光标 → 无。光标模式本身不显示提示。
|
||
switch (Tab().CursorMode())
|
||
{
|
||
case PlotCursorMode::NONE:
|
||
{
|
||
Tab().CursorMode() = PlotCursorMode::SINGLE;
|
||
// 首次启用时置于视图中心,随后可用鼠标靠近拖拽或双击指定精确时间。
|
||
const double center = (Tab().ViewMin() + Tab().ViewMax()) * 0.5;
|
||
Tab().CursorTime() = center;
|
||
Tab().CursorTimeAlt() = center;
|
||
m_cursorHover = true;
|
||
m_hoverCursor = 1;
|
||
m_activeCursor = 1;
|
||
break;
|
||
}
|
||
case PlotCursorMode::SINGLE:
|
||
{
|
||
Tab().CursorMode() = PlotCursorMode::DOUBLE;
|
||
// 光标 2 首次出现:紧邻光标 1 且必须落在当前可见时间范围内,
|
||
// 这样无需额外操作就能立即看到两个光标的对比读数。
|
||
const double low = Tab().ViewMin();
|
||
const double high = Tab().ViewMax();
|
||
const double span = (high > low) ? (high - low) : 1.0;
|
||
const double offset = span * 0.12;
|
||
double second = Tab().CursorTime() + offset;
|
||
if (second > high - span * 0.02)
|
||
{
|
||
second = Tab().CursorTime() - offset;
|
||
}
|
||
if (second < low + span * 0.02 || second > high - span * 0.02)
|
||
{
|
||
second = low + span * 0.5;
|
||
}
|
||
Tab().CursorTimeAlt() = second;
|
||
ClampCursorToView(2);
|
||
m_cursorHover = true;
|
||
m_hoverCursor = 2;
|
||
m_activeCursor = 2;
|
||
break;
|
||
}
|
||
case PlotCursorMode::DOUBLE:
|
||
default:
|
||
DisableCursor();
|
||
break;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
bool PlotView::ResetCursorPlacementOnFreshContent()
|
||
{
|
||
if (m_tab == nullptr || !Tab().CursorEnabled() || m_plotRect.Width <= 0.0f)
|
||
{
|
||
return false;
|
||
}
|
||
// 与 `CycleCursorMode()` 的“光标首次出现”规则完全同源:
|
||
// 单光标置于视图中心;双光标的第二条紧邻第一条、且保证落在可见时间范围内。
|
||
const double low = Tab().ViewMin();
|
||
const double high = Tab().ViewMax();
|
||
const double span = (high > low) ? (high - low) : 1.0;
|
||
const double center = (low + high) * 0.5;
|
||
Tab().CursorTime() = center;
|
||
if (Tab().CursorMode() == PlotCursorMode::DOUBLE)
|
||
{
|
||
const double offset = span * 0.12;
|
||
double second = center + offset;
|
||
if (second > high - span * 0.02)
|
||
{
|
||
second = center - offset;
|
||
}
|
||
if (second < low + span * 0.02 || second > high - span * 0.02)
|
||
{
|
||
second = low + span * 0.5;
|
||
}
|
||
Tab().CursorTimeAlt() = second;
|
||
ClampCursorToView(2);
|
||
}
|
||
ClampCursorToView(1);
|
||
m_cursorHover = true;
|
||
m_hoverCursor = 1;
|
||
m_activeCursor = 1;
|
||
return true;
|
||
}
|
||
|
||
void PlotView::SetHint(const std::wstring& text)
|
||
{
|
||
m_hintText = text;
|
||
m_hintSticky = false;
|
||
m_hintShownAt = (text.empty()) ? 0 : GetTickCount64();
|
||
}
|
||
|
||
void PlotView::SetHintSticky(const std::wstring& text)
|
||
{
|
||
m_hintText = text;
|
||
m_hintSticky = !text.empty();
|
||
m_hintShownAt = (text.empty()) ? 0 : GetTickCount64();
|
||
}
|
||
|
||
void PlotView::ShowPersistentHint(const std::wstring& text)
|
||
{
|
||
SetHintSticky(text);
|
||
}
|
||
|
||
void PlotView::ReleaseHint()
|
||
{
|
||
// 粘滞结束:从这里开始重新计时(先停留再淡出)。
|
||
m_hintSticky = false;
|
||
if (!m_hintText.empty())
|
||
{
|
||
m_hintShownAt = GetTickCount64();
|
||
}
|
||
}
|
||
|
||
bool PlotView::HintNeedsAnimation() const
|
||
{
|
||
if (m_hintText.empty() || m_hintShownAt == 0)
|
||
{
|
||
return false;
|
||
}
|
||
if (m_hintSticky)
|
||
{
|
||
return true;
|
||
}
|
||
const std::uint64_t elapsed = GetTickCount64() - m_hintShownAt;
|
||
return elapsed < (kHintHoldMs + kHintFadeMs);
|
||
}
|
||
|
||
double PlotView::CursorSpan() const
|
||
{
|
||
if (m_tab == nullptr || Tab().CursorMode() != PlotCursorMode::DOUBLE)
|
||
{
|
||
return 0.0;
|
||
}
|
||
return std::fabs(Tab().CursorTimeAlt() - Tab().CursorTime());
|
||
}
|
||
|
||
void PlotView::ClampCursorToView(int which)
|
||
{
|
||
if (m_tab == nullptr || m_plotRect.Width <= 0.0f)
|
||
{
|
||
return;
|
||
}
|
||
const double low = Tab().ViewMin();
|
||
const double high = Tab().ViewMax();
|
||
if (!(high > low))
|
||
{
|
||
return;
|
||
}
|
||
// 只在拖拽时夹取;留一点内缩,保证光标线及把手完整可见。
|
||
// ⚠️ 视图变化(平移 / 缩放 / 自适应 / 滚轮 / 方向键)**不**调用本函数:光标是一个固定的
|
||
// **时间位置**,视界移动时它必须留在原处(可以滑出可视区,此时读数贴在网格边缘显示)。
|
||
const double inset = (high - low) * 0.002;
|
||
if (which == 2)
|
||
{
|
||
Tab().CursorTimeAlt() = ClampDouble(Tab().CursorTimeAlt(), low + inset, high - inset);
|
||
}
|
||
else
|
||
{
|
||
Tab().CursorTime() = ClampDouble(Tab().CursorTime(), low + inset, high - inset);
|
||
}
|
||
}
|
||
|
||
std::uint32_t PlotView::CursorColor() const
|
||
{
|
||
// 两条光标统一使用同一样式与颜色,不按序号区分配色。
|
||
if (m_theme == nullptr)
|
||
{
|
||
return 0xFF000000u;
|
||
}
|
||
return m_theme->cursor;
|
||
}
|
||
|
||
void PlotView::InvalidateDerivedCaches()
|
||
{
|
||
// 后台线程可能在读取采样数据:先暂停并作废异步结果,再清同步缓存。
|
||
PauseDerivedAsync();
|
||
m_polylineCache.clear();
|
||
// 数据层结构变化后边沿统计与均值滤波曲线的派生结果都会失效。
|
||
m_edgeScans.clear();
|
||
m_edgeScanBudget = 0;
|
||
m_meanCurveCache.clear();
|
||
m_weakPeakCurveCache.clear();
|
||
// 信号下标可能已经整体位移:清掉按引用缓存的悬停/拖拽目标,避免指向错误的信号。
|
||
m_hoverSignal = SignalRef();
|
||
m_hoverLegend = SignalRef();
|
||
m_dragSignal = SignalRef();
|
||
m_dragTargetLane = -1;
|
||
// 面板条目栏的悬停同样按引用保存(均值滤波 / 弱峰调整的预览曲线高亮)。
|
||
m_meanRowHoverLane = -1;
|
||
m_meanRowHoverRef = SignalRef();
|
||
InvalidateSceneCache();
|
||
}
|
||
|
||
void PlotView::PauseDerivedAsync()
|
||
{
|
||
// 等待在算任务结束(先发取消),然后清空队列与缓存,并自增代次使任何迟到结果作废。
|
||
StopCurveWorker();
|
||
std::lock_guard<std::mutex> lock(m_curveMutex);
|
||
++m_curveGeneration;
|
||
m_curveJobs.clear();
|
||
m_curveEntries.clear();
|
||
m_curvePins.clear();
|
||
m_curveLatest.clear();
|
||
m_curveHugeActive = 0;
|
||
m_curveSceneDirty.store(false, std::memory_order_relaxed);
|
||
m_curveCancelAll.store(false, std::memory_order_relaxed); // 恢复:下次需要时可以重新开始
|
||
}
|
||
|
||
bool PlotView::AnyDerivedPreviewEnabled() const
|
||
{
|
||
if (m_tab == nullptr)
|
||
{
|
||
return false;
|
||
}
|
||
for (const Lane& lane : m_tab->Lanes())
|
||
{
|
||
if (lane.weakPeak || lane.meanFilter || lane.edgeCenter)
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
void PlotView::TrimDerivedCurveDataIfIdle()
|
||
{
|
||
if (AnyDerivedPreviewEnabled())
|
||
{
|
||
return; // 还有网格在用预览曲线:缓存仍然有用,不能清
|
||
}
|
||
// 停掉后台线程池:这会让各线程的 `thread_local` 工作缓冲**析构并归还内存** ——
|
||
// 这是"关掉预览后内存不下降"的根因(thread_local 只在线程退出时释放)。
|
||
PauseDerivedAsync();
|
||
m_meanCurveCache.clear();
|
||
m_weakPeakCurveCache.clear();
|
||
}
|
||
|
||
bool PlotView::FitTimeToLane(int laneIndex)
|
||
{
|
||
if (m_dataSet == nullptr || m_tab == nullptr)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
const std::vector<Lane>& lanes = m_tab->Lanes();
|
||
if (laneIndex < 0 || laneIndex >= static_cast<int>(lanes.size()))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
double low = std::numeric_limits<double>::infinity();
|
||
double high = -std::numeric_limits<double>::infinity();
|
||
for (const SignalRef& ref : lanes[static_cast<std::size_t>(laneIndex)].signals)
|
||
{
|
||
const TimeAxis* axis = m_dataSet->Axis(ref);
|
||
if (axis == nullptr || axis->finiteCount == 0)
|
||
{
|
||
continue;
|
||
}
|
||
// 与 `TimeBounds` 同源:叠加该信号自己的时间轴偏移(采样画在 t + offsetTime 处)。
|
||
const SignalStyle* style = m_dataSet->Style(ref);
|
||
const double timeShift = (style != nullptr) ? style->offsetTime : 0.0;
|
||
low = std::min(low, axis->minTime + timeShift);
|
||
high = std::max(high, axis->maxTime + timeShift);
|
||
}
|
||
|
||
if (!std::isfinite(low) || !std::isfinite(high) || !(high > low))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
SetTimeView(low, high);
|
||
Tab().ViewInitialized() = true;
|
||
// 与“适应视图”保持一致:退出选区放大模式,避免模式按钮与实际状态不符。
|
||
Tab().ZoomMode() = PlotZoomMode::NONE;
|
||
m_rubberRect = gd::RectF();
|
||
m_dragMode = PlotDragMode::NONE;
|
||
m_rubberAxis = PlotRubberAxis::UNDECIDED;
|
||
|
||
// 针对本网格信号范围展开时间轴后,同时把该网格的 Y 轴自适应一次,
|
||
// 否则展开后会看到“时间对了但幅值区间不合适”的波形。
|
||
AutoRangeLaneToView(laneIndex);
|
||
|
||
// 其它开启了「选区 Y 轴自适应」的网格同样要跟新的时间视图重新匹配:
|
||
// 时间视图变了,只让被展开的那个网格合适、其余网格停留在旧幅值区间并不符合预期。
|
||
ApplySelectionAutoFit();
|
||
|
||
// 注意:不在这里移动光标——光标允许滞留在网格可视区域之外。
|
||
BuildLaneVisuals();
|
||
return true;
|
||
}
|
||
|
||
bool PlotView::MinorCellSparse(const TimeAxis& axis, const SignalData& signal,
|
||
double timeShift) const
|
||
{
|
||
const int cells = GridMinorCellCount();
|
||
if (cells <= 0 || signal.finiteCount == 0)
|
||
{
|
||
return false;
|
||
}
|
||
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, timeShift);
|
||
if (end <= begin)
|
||
{
|
||
return false;
|
||
}
|
||
// 视界内一个可见数据点都没有:既没有要提示的位置,也不该因为“小格全空”而无意义地抖动。
|
||
if (!HasVisibleSample(axis, signal, begin, end, timeShift))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
const double viewMin = Tab().ViewMin() - timeShift;
|
||
const double viewMax = Tab().ViewMax() - timeShift;
|
||
const double span = viewMax - viewMin;
|
||
if (!(span > 0.0))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
// ⚠️⚠️ **判定只在这个信号【实际有数据】的时间段内进行**(用户 2026-10-17 报告):
|
||
// 同时加载 `VERY_BIG_DATA`(视界跨度 ~130s)与 MF4(数据只覆盖 ~11s)时,
|
||
// 旧实现把**整个视界**切成小格 —— MF4 信号在 90% 的格子里本来就没有任何采样点,
|
||
// 于是“存在一个格子内 < 5 个点”恒成立 ⇒ **默认状态下就被错判成稀疏、自动切点线**。
|
||
// 现在先用「可见数据的显示时间跨度」把格子范围收窄,只对这些格子做判定。
|
||
double dataMin = viewMin;
|
||
double dataMax = viewMax;
|
||
{
|
||
// 绝大多数信号的两端采样本就有效 ⇒ **零扫描**快速路径(每帧 × 上百个信号,这很重要)。
|
||
const bool edgeOk =
|
||
std::isfinite(signal.values[begin]) && std::isfinite(axis.time[begin]) &&
|
||
std::isfinite(signal.values[end - 1]) && std::isfinite(axis.time[end - 1]);
|
||
if (edgeOk)
|
||
{
|
||
dataMin = axis.time[begin];
|
||
dataMax = axis.time[end - 1];
|
||
}
|
||
else
|
||
{
|
||
// 端点无效(NaN)时向内侧做**有界**探测(NaN 只出现在缺数据段,几步内就能找到);
|
||
// 探测不到就回退到整段轴区间(宁可多算几格,也不要放弃判定)。
|
||
const std::size_t probe = 64u;
|
||
const std::size_t forwardCap = std::min(end, begin + probe);
|
||
bool found = false;
|
||
for (std::size_t index = begin; index < forwardCap; ++index)
|
||
{
|
||
if (std::isfinite(signal.values[index]) && std::isfinite(axis.time[index]))
|
||
{
|
||
dataMin = axis.time[index];
|
||
found = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!found)
|
||
{
|
||
dataMin = axis.time[begin];
|
||
}
|
||
const std::size_t backwardFloor =
|
||
(end > probe) ? std::max(begin, end - probe) : begin;
|
||
found = false;
|
||
for (std::size_t index = end; index-- > backwardFloor;)
|
||
{
|
||
if (std::isfinite(signal.values[index]) && std::isfinite(axis.time[index]))
|
||
{
|
||
dataMax = axis.time[index];
|
||
found = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!found)
|
||
{
|
||
dataMax = axis.time[end - 1];
|
||
}
|
||
}
|
||
dataMin = std::max(dataMin, viewMin);
|
||
dataMax = std::min(dataMax, viewMax);
|
||
// ⚠️ **`dataMax == dataMin` 是合法且常见的情形**(视界内只有 1 个有效采样点,
|
||
// 典型:单数据点信号)。旧实现在这里直接 `return false`,等于把"只有一个点"
|
||
// 的信号判成"不稀疏" ⇒ 光标不会吸附到那个点上
|
||
// (用户 2026-10-29 报告"单数据点信号的吸附没了")。
|
||
// 正确做法是让它走下面"覆盖那唯一一个小格"的路径:
|
||
// `firstCell == lastCell`,`need = 5`,而 `finiteCount == 1 < 5` ⇒ 判为稀疏。
|
||
if (dataMax < dataMin)
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
|
||
const double cellSpan = span / static_cast<double>(cells);
|
||
if (!(cellSpan > 0.0) || !std::isfinite(cellSpan))
|
||
{
|
||
return false;
|
||
}
|
||
// 只覆盖「有数据的显示时间范围」所涉及的格子(闭区间)。
|
||
int firstCell = static_cast<int>(std::floor((dataMin - viewMin) / cellSpan));
|
||
int lastCell = static_cast<int>(std::floor((dataMax - viewMin) / cellSpan));
|
||
firstCell = std::max(0, std::min(firstCell, cells - 1));
|
||
lastCell = std::max(0, std::min(lastCell, cells - 1));
|
||
if (lastCell < firstCell)
|
||
{
|
||
return false;
|
||
}
|
||
const int consideredCells = lastCell - firstCell + 1;
|
||
|
||
// 鸽巢原理:有效点总数都不足“每格 5 个” ⇒ 必然存在一个稀疏小格(O(1) 充分判定)。
|
||
// ⚠️ 分母必须用**实际参与的格子数**(不是整个视界的格子数)。
|
||
const std::size_t need =
|
||
static_cast<std::size_t>(consideredCells) * static_cast<std::size_t>(kSparseMinPointsPerCell);
|
||
if (signal.finiteCount < need)
|
||
{
|
||
return true;
|
||
}
|
||
|
||
// 时间轴单调 ⇒ 每个小格用两次二分取出它的样本区间(代价与采样数无关)。
|
||
if (axis.monotonic)
|
||
{
|
||
for (int cell = firstCell; cell <= lastCell; ++cell)
|
||
{
|
||
const double cellMin = viewMin + cellSpan * static_cast<double>(cell);
|
||
const double cellMax =
|
||
(cell + 1 == cells)
|
||
? viewMax
|
||
: (viewMin + cellSpan * static_cast<double>(cell + 1));
|
||
// ⚠️ 二分作用在整条 `axis.time` 上,必须夹到 `count`(= min(时间轴长度, values 长度)),
|
||
// 否则分组时间轴比这条信号长时会越界读 `signal.values[index]`。
|
||
const std::size_t lo = std::min(
|
||
count, static_cast<std::size_t>(
|
||
std::lower_bound(axis.time.begin(), axis.time.end(), cellMin) - axis.time.begin()));
|
||
const std::size_t hi = std::min(
|
||
count, static_cast<std::size_t>(
|
||
std::lower_bound(axis.time.begin(), axis.time.end(), cellMax) - axis.time.begin()));
|
||
const std::size_t cap = std::min(hi, lo + kSparseCellScanCap);
|
||
std::size_t valid = 0;
|
||
for (std::size_t index = lo; index < cap; ++index)
|
||
{
|
||
if (!std::isfinite(signal.values[index]))
|
||
{
|
||
continue;
|
||
}
|
||
if (++valid >= static_cast<std::size_t>(kSparseMinPointsPerCell))
|
||
{
|
||
break;
|
||
}
|
||
}
|
||
if (valid < static_cast<std::size_t>(kSparseMinPointsPerCell))
|
||
{
|
||
return true; // 该小格稀疏 ⇒ 自动切「点线」
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// 非单调时间轴:一次有界扫描,按小格分桶计数。
|
||
std::vector<int> buckets(static_cast<std::size_t>(cells), 0);
|
||
const std::size_t scanLimit =
|
||
kSparseCellScanCap * static_cast<std::size_t>(cells);
|
||
std::size_t scanned = 0;
|
||
for (std::size_t index = begin; index < end; ++index)
|
||
{
|
||
if (++scanned > scanLimit)
|
||
{
|
||
break;
|
||
}
|
||
if (!std::isfinite(signal.values[index]))
|
||
{
|
||
continue;
|
||
}
|
||
const double time = axis.time[index];
|
||
if (!std::isfinite(time) || time < viewMin || time > viewMax)
|
||
{
|
||
continue;
|
||
}
|
||
int cell = static_cast<int>((time - viewMin) / cellSpan);
|
||
if (cell < 0)
|
||
{
|
||
cell = 0;
|
||
}
|
||
if (cell >= cells)
|
||
{
|
||
cell = cells - 1;
|
||
}
|
||
const std::size_t slot = static_cast<std::size_t>(cell);
|
||
if (buckets[slot] < kSparseMinPointsPerCell)
|
||
{
|
||
++buckets[slot];
|
||
}
|
||
}
|
||
for (int cell = firstCell; cell <= lastCell; ++cell)
|
||
{
|
||
if (buckets[static_cast<std::size_t>(cell)] < kSparseMinPointsPerCell)
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
bool PlotView::NearestSnapSample(const TimeAxis& axis, const SignalData& signal, double time,
|
||
double timeShift, double& candidate) const
|
||
{
|
||
// ⚠️ `time` / `candidate` 都是**显示时间**(画面上的时刻):采样点 k 画在 `time[k] + timeShift`
|
||
// 处,而光标存的也是显示时间,因此查找与可见性判定都要整体平移(否则存在
|
||
// 「调整 → 时间轴偏移」时吸附会落在历史位置上,用户 2026-10-15 报告)。
|
||
const double viewMin = Tab().ViewMin() - timeShift;
|
||
const double viewMax = Tab().ViewMax() - timeShift;
|
||
|
||
// 稀疏信号:有效点已在加载时缓存(按时间升序)⇒ 二分取最近的一个。
|
||
if (!signal.sparseTimes.empty())
|
||
{
|
||
const std::vector<double>& times = signal.sparseTimes;
|
||
const double target = time - timeShift;
|
||
const std::vector<double>::const_iterator it = std::lower_bound(times.begin(), times.end(), target);
|
||
bool found = false;
|
||
double bestDistance = std::numeric_limits<double>::infinity();
|
||
for (int side = 0; side < 2; ++side)
|
||
{
|
||
// side 0 = 第一个 >= target 的点;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 + timeShift) - time);
|
||
if (distance < bestDistance)
|
||
{
|
||
bestDistance = distance;
|
||
candidate = value + timeShift;
|
||
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 - timeShift);
|
||
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 + timeShift;
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
bool PlotView::SnapTimeToSample(double time, double& snapped) const
|
||
{
|
||
if (m_dataSet == nullptr || m_tab == nullptr)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
// 吸附判定**只针对鼠标所在的网格**(用户明确要求):
|
||
// 一个网格内的数据已达“稀疏”标准、另一个没有时,光标只在前者上吸附,
|
||
// 在后者上仍然完全自由移动。
|
||
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;
|
||
|
||
// 同一个网格内可能存在多条信号:**所有**符合吸附判定的数据点都参与吸附。
|
||
for (const SignalRef& ref : m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].signals)
|
||
{
|
||
if (!m_tab->IsVisible(ref))
|
||
{
|
||
continue;
|
||
}
|
||
const SignalData* signal = m_dataSet->Resolve(ref);
|
||
const TimeAxis* axis = m_dataSet->Axis(ref);
|
||
const SignalStyle* style = m_dataSet->Style(ref);
|
||
if (signal == nullptr || axis == nullptr)
|
||
{
|
||
continue;
|
||
}
|
||
// ⚠️ 一切吸附判定与查找都在**显示时间**空间进行:采样点 k 画在
|
||
// `time[k] + offsetTime` 处,而光标存的也是显示时间。
|
||
// 少了这一步,信号一旦设置过「调整 → 时间轴偏移」,吸附就会落在**历史位置**
|
||
// (用户 2026-10-15 报告“存在时间轴偏移调整时无法吸附到正确位置”)。
|
||
const double timeShift = (style != nullptr) ? style->offsetTime : 0.0;
|
||
// ⚠️ 判定与「放大自动切点线」**完全同源**(用户 2026-10-17 明确要求“同样的状态判定”):
|
||
// 只要存在任何一个小格内的数据点少于 5 个就参与吸附;
|
||
// 判定**不看绘制方式** ⇒ 即使用户手动改过显示样式,该吸附的照样吸附。
|
||
// 网格内既有符合又有不符合的信号时,只有符合的那条的数据点进入比较(用户要求)。
|
||
if (!MinorCellSparse(*axis, *signal, timeShift))
|
||
{
|
||
continue;
|
||
}
|
||
double candidate = 0.0;
|
||
if (!NearestSnapSample(*axis, *signal, time, timeShift, candidate))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
// 吸附半径再受“半个局部采样间距”限制(点很稀疏时不被吸死)。
|
||
double limitTime = radiusTime;
|
||
double spacingTime = 0.0;
|
||
LocalSampleSpacing(*axis, *signal, candidate, timeShift, spacingTime);
|
||
if (spacingTime > 0.0)
|
||
{
|
||
limitTime = std::min(limitTime, spacingTime * 0.5);
|
||
}
|
||
|
||
const double distance = std::fabs(candidate - time);
|
||
if (distance <= limitTime && distance < bestDistance)
|
||
{
|
||
bestDistance = distance;
|
||
bestTime = candidate;
|
||
found = true;
|
||
}
|
||
}
|
||
|
||
if (found)
|
||
{
|
||
snapped = bestTime;
|
||
}
|
||
return found;
|
||
}
|
||
|
||
void PlotView::PlaceCursorTime(int which, double time)
|
||
{
|
||
if (!std::isfinite(time))
|
||
{
|
||
// NaN/Inf 会让后面所有比较都失效(`ClampDouble` 也拦不住 NaN),
|
||
// 光标会被画到画布之外:直接丢弃这次位置更新。
|
||
return;
|
||
}
|
||
|
||
double snapped = 0.0;
|
||
if (SnapTimeToSample(time, snapped))
|
||
{
|
||
time = snapped;
|
||
}
|
||
|
||
if (which == 2)
|
||
{
|
||
Tab().CursorTimeAlt() = time;
|
||
}
|
||
else
|
||
{
|
||
Tab().CursorTime() = time;
|
||
}
|
||
ClampCursorToView(which);
|
||
}
|
||
|
||
PlotZoomMode PlotView::CurrentZoomMode() const
|
||
{
|
||
return Tab().ZoomMode();
|
||
}
|
||
|
||
bool PlotView::ToggleZoomMode(PlotZoomMode mode)
|
||
{
|
||
const PlotZoomMode current = Tab().ZoomMode();
|
||
Tab().ZoomMode() = (current == mode) ? PlotZoomMode::NONE : mode;
|
||
m_rubberAxis = PlotRubberAxis::UNDECIDED;
|
||
m_rubberRect = gd::RectF();
|
||
SetHint(Tab().ZoomMode() == PlotZoomMode::NONE
|
||
? std::wstring()
|
||
: L"横向或纵向拖动以放大选择区域,ESC退出选区模式");
|
||
return true;
|
||
}
|
||
|
||
float PlotView::CursorX(int which) const
|
||
{
|
||
const double time = (which == 2) ? Tab().CursorTimeAlt() : Tab().CursorTime();
|
||
return TimeToX(time);
|
||
}
|
||
|
||
float PlotView::CursorDisplayX(int which) const
|
||
{
|
||
// 光标允许滞留在可视区之外:此时把它"钉"在最近的网格边缘上显示,
|
||
// 保证它的取值与时间读数始终可见。
|
||
const float raw = CursorX(which);
|
||
return std::max(m_plotRect.X + 1.0f * m_scale,
|
||
std::min(raw, m_plotRect.X + m_plotRect.Width - 1.0f * m_scale));
|
||
}
|
||
|
||
gd::RectF PlotView::CursorLabelRect(int which) const
|
||
{
|
||
if (m_fonts == nullptr || m_fonts->monoSmall == nullptr)
|
||
{
|
||
return gd::RectF();
|
||
}
|
||
|
||
// 未经叠放处理的"原始"位置:单条标签独占底部一行。
|
||
const auto rawRect = [this](int index) -> gd::RectF
|
||
{
|
||
const double time = (index == 2) ? Tab().CursorTimeAlt() : Tab().CursorTime();
|
||
const std::wstring text = FormatCursorTime(time);
|
||
const float width =
|
||
std::max(64.0f * m_scale, UiMeasureTextWidth(m_fonts->monoSmall, text) + 16.0f * m_scale);
|
||
const float height = 20.0f * m_scale;
|
||
|
||
// 时间读数改到绘图区**底部**:顶部留给网格图例与幅值标尺,避免互相遮挡。
|
||
float left = CursorDisplayX(index) - width * 0.5f;
|
||
left = std::max(m_plotRect.X, std::min(left, m_plotRect.X + m_plotRect.Width - width));
|
||
|
||
// 光标滞留在视界之外时,胶囊外侧还要放一个方向三角(与网格内数据点越界同一套画法):
|
||
// 把胶囊整体**向内**让出三角 + 间隙的宽度,否则贴边时三角没有落脚之处、
|
||
// 只能压在胶囊上(用户报告的"没有间隔、样式不统一")。
|
||
bool offRight = false;
|
||
if (CursorOffViewSide(index, offRight))
|
||
{
|
||
const float reserved = (kMarkerTriangleDepth + kMarkerTriangleGap) * m_scale;
|
||
left = offRight ? (left - reserved) : (left + reserved);
|
||
left = std::max(m_plotRect.X, std::min(left, m_plotRect.X + m_plotRect.Width - width));
|
||
}
|
||
|
||
const float top = m_plotRect.Y + m_plotRect.Height - height - 2.0f * m_scale;
|
||
|
||
return gd::RectF(left, top, width, height);
|
||
};
|
||
|
||
gd::RectF rect = rawRect(which);
|
||
|
||
// 双光标靠得很近时,两条时间读数标签会互相覆盖:把它们分两行叠放。
|
||
// **小值在上、大值在下**(用户明确要求)——按时间值决定行序,
|
||
// 而不是固定让光标 2 上移。左/右光标被拖动交换后排序依然正确。
|
||
if (Tab().CursorMode() == PlotCursorMode::DOUBLE)
|
||
{
|
||
const gd::RectF first = rawRect(1);
|
||
const gd::RectF second = rawRect(2);
|
||
// 方向三角已移到胶囊**外侧**,胶囊之间不再需要额外留白。
|
||
const float gap = 2.0f * m_scale;
|
||
const bool overlapX = (first.X < second.X + second.Width + gap) &&
|
||
(second.X < first.X + first.Width + gap);
|
||
if (overlapX)
|
||
{
|
||
const bool firstIsSmaller = (Tab().CursorTime() <= Tab().CursorTimeAlt());
|
||
// which == 1 是否属于"小值"。
|
||
const bool whichIsSmaller = (which == 1) ? firstIsSmaller : !firstIsSmaller;
|
||
const float bottomRow = rawRect(1).Y;
|
||
const float topRow = std::max(m_plotRect.Y + 2.0f * m_scale,
|
||
bottomRow - rect.Height - gap);
|
||
rect.Y = whichIsSmaller ? topRow : bottomRow;
|
||
}
|
||
}
|
||
|
||
return rect;
|
||
}
|
||
|
||
bool PlotView::LaneAutoFitToSelection(int laneIndex) const
|
||
{
|
||
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
|
||
{
|
||
return false;
|
||
}
|
||
return m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].fitYToSelection;
|
||
}
|
||
|
||
bool PlotView::AllLanesAutoFitToSelection() const
|
||
{
|
||
if (m_tab == nullptr)
|
||
{
|
||
return false;
|
||
}
|
||
const std::vector<Lane>& lanes = m_tab->Lanes();
|
||
if (lanes.empty())
|
||
{
|
||
return false;
|
||
}
|
||
for (const Lane& lane : lanes)
|
||
{
|
||
if (!lane.fitYToSelection)
|
||
{
|
||
// 任一网格未开启 → 全局项就不是勾选状态。
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
bool PlotView::LaneEdgeCount(int laneIndex) const
|
||
{
|
||
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
|
||
{
|
||
return false;
|
||
}
|
||
return m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].edgeCount;
|
||
}
|
||
|
||
bool PlotView::SetLaneEdgeCount(int laneIndex, bool enabled)
|
||
{
|
||
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
|
||
{
|
||
return false;
|
||
}
|
||
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
|
||
if (lane.edgeCount == enabled)
|
||
{
|
||
return false;
|
||
}
|
||
lane.edgeCount = enabled;
|
||
if (enabled)
|
||
{
|
||
// 首次开启:把横向光标放到可见幅值范围的中点,并让面板重新展开。
|
||
if (!lane.edgeValueValid)
|
||
{
|
||
double yMin = 0.0;
|
||
double yMax = 1.0;
|
||
LaneRange(laneIndex, yMin, yMax);
|
||
lane.edgeValue = (yMin + yMax) * 0.5;
|
||
lane.edgeValueValid = true;
|
||
}
|
||
lane.edgePanel.open = true;
|
||
}
|
||
else
|
||
{
|
||
lane.edgePanel.open = false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
bool PlotView::ToggleLaneEdgeCount(int laneIndex)
|
||
{
|
||
const bool enabled = !LaneEdgeCount(laneIndex);
|
||
SetLaneEdgeCount(laneIndex, enabled);
|
||
return enabled;
|
||
}
|
||
|
||
bool PlotView::LaneEdgeCenterCount(int laneIndex) const
|
||
{
|
||
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
|
||
{
|
||
return false;
|
||
}
|
||
return m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].edgeCenter;
|
||
}
|
||
|
||
bool PlotView::SetLaneEdgeCenterCount(int laneIndex, bool enabled)
|
||
{
|
||
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
|
||
{
|
||
return false;
|
||
}
|
||
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
|
||
if (lane.edgeCenter == enabled)
|
||
{
|
||
return false;
|
||
}
|
||
lane.edgeCenter = enabled;
|
||
if (enabled)
|
||
{
|
||
// 与「均值滤波」完全一致:默认只勾选网格内**第一个可见信号**。
|
||
if (lane.edgeCenterItems.empty())
|
||
{
|
||
for (const SignalRef& ref : lane.signals)
|
||
{
|
||
const ResolvedSignal resolved = ResolveSignal(ref);
|
||
if (resolved.data != nullptr && resolved.visible)
|
||
{
|
||
lane.EnsureEdgeCenterItem(ref);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
lane.edgeCenterPanel.open = true;
|
||
}
|
||
else
|
||
{
|
||
lane.edgeCenterPanel.open = false;
|
||
}
|
||
// 关闭后若本标签页**已无任何预览曲线功能**,立即释放派生曲线数据(缓存 + 后台线程的
|
||
// thread_local 工作缓冲)—— 否则关掉面板后内存不会下降(用户报告)。
|
||
if (!enabled)
|
||
{
|
||
TrimDerivedCurveDataIfIdle();
|
||
}
|
||
// 预建「宽缓冲基底」,避免首次拖动参数滑块时的一次性重建尖峰。
|
||
// ⚠️ 只补「缺失 / 失效」的网格,**不**整批重建(见 `EnsureLaneLayersPrefetched`)。
|
||
EnsureLaneLayersPrefetched();
|
||
return true;
|
||
}
|
||
|
||
bool PlotView::ToggleLaneEdgeCenterCount(int laneIndex)
|
||
{
|
||
const bool enabled = !LaneEdgeCenterCount(laneIndex);
|
||
SetLaneEdgeCenterCount(laneIndex, enabled);
|
||
return enabled;
|
||
}
|
||
|
||
bool PlotView::ToggleLaneEdgeCenterSignal(int laneIndex, const SignalRef& ref)
|
||
{
|
||
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
|
||
{
|
||
return false;
|
||
}
|
||
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
|
||
if (lane.EdgeCenterEnabled(ref))
|
||
{
|
||
lane.RemoveEdgeCenterItem(ref);
|
||
// 该信号的累积统计状态一并释放(否则会占着 `kEdgeScanStateLimit` 的名额)。
|
||
InvalidateEdgeScansFor(ResolveSignal(ref).data, true);
|
||
return false;
|
||
}
|
||
lane.EnsureEdgeCenterItem(ref);
|
||
return true;
|
||
}
|
||
|
||
bool PlotView::LaneEdgeCenterSignalEnabled(int laneIndex, const SignalRef& ref) const
|
||
{
|
||
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
|
||
{
|
||
return false;
|
||
}
|
||
return m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].EdgeCenterEnabled(ref);
|
||
}
|
||
|
||
bool PlotView::LaneMeanFilter(int laneIndex) const
|
||
{
|
||
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
|
||
{
|
||
return false;
|
||
}
|
||
return m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].meanFilter;
|
||
}
|
||
|
||
bool PlotView::LaneWeakPeak(int laneIndex) const
|
||
{
|
||
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
|
||
{
|
||
return false;
|
||
}
|
||
return m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].weakPeak;
|
||
}
|
||
|
||
bool PlotView::SetLaneWeakPeak(int laneIndex, bool enabled)
|
||
{
|
||
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
|
||
{
|
||
return false;
|
||
}
|
||
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
|
||
if (lane.weakPeak == enabled)
|
||
{
|
||
return false;
|
||
}
|
||
lane.weakPeak = enabled;
|
||
if (enabled)
|
||
{
|
||
// 与「均值滤波」完全一致:默认只勾选网格内**第一个可见信号**。
|
||
if (lane.weakPeakItems.empty())
|
||
{
|
||
for (const SignalRef& ref : lane.signals)
|
||
{
|
||
const ResolvedSignal resolved = ResolveSignal(ref);
|
||
if (resolved.data != nullptr && resolved.visible)
|
||
{
|
||
lane.EnsureWeakPeakItem(ref);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
lane.weakPeakPanel.open = true;
|
||
}
|
||
else
|
||
{
|
||
lane.weakPeakPanel.open = false;
|
||
}
|
||
// 关闭后若本标签页**已无任何预览曲线功能**,立即释放派生曲线数据(缓存 + 后台线程的
|
||
// thread_local 工作缓冲)—— 否则关掉面板后内存不会下降(用户报告)。
|
||
if (!enabled)
|
||
{
|
||
TrimDerivedCurveDataIfIdle();
|
||
}
|
||
// 预建「宽缓冲基底」(不含派生曲线):只为**尚未建立 / 已失效**的网格补图层,
|
||
// 避免用户首次拖动该滑块时一次性重建的尖峰。
|
||
// ⚠️⚠️ **绝不能在这里无条件重建【全部】网格**(旧实现是 `RebuildWideBuffer()`):
|
||
// “全部信号各自一块网格”时可达上百个网格、其中可能含着千万级采样的巨型信号,
|
||
// 整批重渲染要**几百毫秒** ⇒ 用户点一下功能开关 / 关闭浮动面板就会“卡住一段时间”
|
||
//(用户 2026-10-17 报告:三明治加载后开关面板会卡、关面板要等一会才消失)。
|
||
// ✓ 只有「图层数组与网格数量不符」(首次绘制 / 增删网格)才需要同步整批建立,
|
||
// 其余只登记进待重画队列,由既有预算机制(每帧最多一个网格)逐帧补齐。
|
||
EnsureLaneLayersPrefetched();
|
||
return true;
|
||
}
|
||
|
||
bool PlotView::ToggleLaneWeakPeak(int laneIndex)
|
||
{
|
||
const bool enabled = !LaneWeakPeak(laneIndex);
|
||
SetLaneWeakPeak(laneIndex, enabled);
|
||
return enabled;
|
||
}
|
||
|
||
bool PlotView::ToggleLaneWeakPeakSignal(int laneIndex, const SignalRef& ref)
|
||
{
|
||
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
|
||
{
|
||
return false;
|
||
}
|
||
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
|
||
if (lane.WeakPeakEnabled(ref))
|
||
{
|
||
lane.RemoveWeakPeakItem(ref);
|
||
return false;
|
||
}
|
||
lane.EnsureWeakPeakItem(ref);
|
||
return true;
|
||
}
|
||
|
||
bool PlotView::LaneWeakPeakSignalEnabled(int laneIndex, const SignalRef& ref) const
|
||
{
|
||
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
|
||
{
|
||
return false;
|
||
}
|
||
return m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].WeakPeakEnabled(ref);
|
||
}
|
||
|
||
bool PlotView::TakePendingWeakPeakBuild(int& laneIndex, std::vector<WeakPeakBuild>& items)
|
||
{
|
||
if (m_pendingWeakPeakLane < 0)
|
||
{
|
||
return false;
|
||
}
|
||
laneIndex = m_pendingWeakPeakLane;
|
||
items = m_pendingWeakPeakItems;
|
||
m_pendingWeakPeakLane = -1;
|
||
m_pendingWeakPeakItems.clear();
|
||
return true;
|
||
}
|
||
|
||
bool PlotView::SetLaneMeanFilter(int laneIndex, bool enabled)
|
||
{
|
||
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
|
||
{
|
||
return false;
|
||
}
|
||
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
|
||
if (lane.meanFilter == enabled)
|
||
{
|
||
return false;
|
||
}
|
||
lane.meanFilter = enabled;
|
||
if (enabled)
|
||
{
|
||
// 默认只勾选网格内**第一个可见信号**(用户明确要求),其余保持未勾选。
|
||
if (lane.meanFilterItems.empty())
|
||
{
|
||
for (const SignalRef& ref : lane.signals)
|
||
{
|
||
const ResolvedSignal resolved = ResolveSignal(ref);
|
||
if (resolved.data != nullptr && resolved.visible)
|
||
{
|
||
lane.EnsureMeanFilterItem(ref);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
lane.meanFilterPanel.open = true;
|
||
}
|
||
else
|
||
{
|
||
lane.meanFilterPanel.open = false;
|
||
}
|
||
// 关闭后若本标签页**已无任何预览曲线功能**,立即释放派生曲线数据(缓存 + 后台线程的
|
||
// thread_local 工作缓冲)—— 否则关掉面板后内存不会下降(用户报告)。
|
||
if (!enabled)
|
||
{
|
||
TrimDerivedCurveDataIfIdle();
|
||
}
|
||
// 预建「宽缓冲基底」,避免首次拖动参数滑块时的一次性重建尖峰。
|
||
// ⚠️ 只补「缺失 / 失效」的网格,**不**整批重建(见 `EnsureLaneLayersPrefetched`)。
|
||
EnsureLaneLayersPrefetched();
|
||
return true;
|
||
}
|
||
|
||
bool PlotView::ToggleLaneMeanFilterSignal(int laneIndex, const SignalRef& ref)
|
||
{
|
||
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
|
||
{
|
||
return false;
|
||
}
|
||
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
|
||
if (lane.MeanFilterEnabled(ref))
|
||
{
|
||
lane.RemoveMeanFilterItem(ref);
|
||
return false;
|
||
}
|
||
lane.EnsureMeanFilterItem(ref);
|
||
return true;
|
||
}
|
||
|
||
bool PlotView::LaneMeanFilterSignalEnabled(int laneIndex, const SignalRef& ref) const
|
||
{
|
||
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
|
||
{
|
||
return false;
|
||
}
|
||
return m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].MeanFilterEnabled(ref);
|
||
}
|
||
|
||
bool PlotView::ToggleLaneMeanFilter(int laneIndex)
|
||
{
|
||
const bool enabled = !LaneMeanFilter(laneIndex);
|
||
SetLaneMeanFilter(laneIndex, enabled);
|
||
return enabled;
|
||
}
|
||
|
||
bool PlotView::TakePendingMeanFilterBuild(int& laneIndex, std::vector<MeanFilterBuild>& items)
|
||
{
|
||
if (m_pendingMeanFilterLane < 0)
|
||
{
|
||
return false;
|
||
}
|
||
laneIndex = m_pendingMeanFilterLane;
|
||
items = m_pendingMeanFilterItems;
|
||
m_pendingMeanFilterLane = -1;
|
||
m_pendingMeanFilterItems.clear();
|
||
return true;
|
||
}
|
||
|
||
bool PlotView::SetLaneFreeMarker(int laneIndex, bool enabled)
|
||
{
|
||
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
|
||
{
|
||
return false;
|
||
}
|
||
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
|
||
if (lane.freeMarker == enabled)
|
||
{
|
||
return false;
|
||
}
|
||
lane.freeMarker = enabled;
|
||
// 关闭时**保留**已固定的点(用户可能只是暂时收起光标,再打开还能继续看);
|
||
// 清空固定点由面板上的 ✕ 负责。
|
||
lane.freeMarkerPanel.open = enabled;
|
||
if (!enabled && m_freeMarker.laneIndex == laneIndex)
|
||
{
|
||
m_freeMarker = FreeMarkerProbe(); // 跟随光标立即失效
|
||
}
|
||
// 自由标记只画叠加层(不进图层位图),但网格底衬仍可能需要预建,
|
||
// 与其它实验性功能保持同一套"只补缺失图层"的处理。
|
||
EnsureLaneLayersPrefetched();
|
||
return true;
|
||
}
|
||
|
||
bool PlotView::LaneFreeMarker(int laneIndex) const
|
||
{
|
||
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
|
||
{
|
||
return false;
|
||
}
|
||
return m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].freeMarker;
|
||
}
|
||
|
||
bool PlotView::ToggleLaneFreeMarker(int laneIndex)
|
||
{
|
||
const bool enabled = !LaneFreeMarker(laneIndex);
|
||
SetLaneFreeMarker(laneIndex, enabled);
|
||
return enabled;
|
||
}
|
||
|
||
bool PlotView::SetLaneFreeMarkerCursor(int laneIndex, bool enabled)
|
||
{
|
||
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
|
||
{
|
||
return false;
|
||
}
|
||
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
|
||
if (!lane.freeMarker || lane.freeMarkerCursor == enabled)
|
||
{
|
||
return false;
|
||
}
|
||
lane.freeMarkerCursor = enabled;
|
||
// 光标模式的可见结果依赖鼠标位置,模式一变必须让已缓存的探针立刻作废,
|
||
// 否则关闭后仍会残留一条纵向光标(或开启后要等下一次移动才出现)。
|
||
if (m_freeMarker.laneIndex == laneIndex)
|
||
{
|
||
m_freeMarker = FreeMarkerProbe();
|
||
}
|
||
return true;
|
||
}
|
||
|
||
bool PlotView::LaneFreeMarkerCursor(int laneIndex) const
|
||
{
|
||
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
|
||
{
|
||
return false;
|
||
}
|
||
const Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
|
||
return lane.freeMarker && lane.freeMarkerCursor;
|
||
}
|
||
|
||
bool PlotView::ToggleLaneFreeMarkerCursor(int laneIndex)
|
||
{
|
||
const bool enabled = !LaneFreeMarkerCursor(laneIndex);
|
||
SetLaneFreeMarkerCursor(laneIndex, enabled);
|
||
return enabled;
|
||
}
|
||
|
||
bool PlotView::SetLaneFreeMarkerShowDelta(int laneIndex, bool enabled)
|
||
{
|
||
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
|
||
{
|
||
return false;
|
||
}
|
||
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
|
||
if (lane.freeMarkerShowDelta == enabled)
|
||
{
|
||
return false;
|
||
}
|
||
lane.freeMarkerShowDelta = enabled;
|
||
return true;
|
||
}
|
||
|
||
bool PlotView::SetLaneFreeMarkerShowEnum(int laneIndex, bool enabled)
|
||
{
|
||
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
|
||
{
|
||
return false;
|
||
}
|
||
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
|
||
if (lane.freeMarkerShowEnum == enabled)
|
||
{
|
||
return false;
|
||
}
|
||
lane.freeMarkerShowEnum = enabled;
|
||
return true;
|
||
}
|
||
|
||
bool PlotView::LaneFreeMarkerShowDelta(int laneIndex) const
|
||
{
|
||
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
|
||
{
|
||
return false;
|
||
}
|
||
return m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].freeMarkerShowDelta;
|
||
}
|
||
|
||
bool PlotView::LaneFreeMarkerShowEnum(int laneIndex) const
|
||
{
|
||
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
|
||
{
|
||
return false;
|
||
}
|
||
return m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].freeMarkerShowEnum;
|
||
}
|
||
|
||
bool PlotView::SetLaneFreeMarkerOpacity(int laneIndex, float opacity)
|
||
{
|
||
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
|
||
{
|
||
return false;
|
||
}
|
||
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
|
||
const float clamped = std::max(0.0f, std::min(1.0f, opacity));
|
||
if (std::fabs(lane.freeMarkerOpacity - clamped) < 0.0005f)
|
||
{
|
||
return false;
|
||
}
|
||
lane.freeMarkerOpacity = clamped;
|
||
return true;
|
||
}
|
||
|
||
bool PlotView::SetLaneFreeMarkerScroll(int laneIndex, float scroll)
|
||
{
|
||
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
|
||
{
|
||
return false;
|
||
}
|
||
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
|
||
const float clamped = std::max(0.0f, scroll);
|
||
if (std::fabs(lane.freeMarkerScroll - clamped) < 0.5f)
|
||
{
|
||
return false;
|
||
}
|
||
lane.freeMarkerScroll = clamped;
|
||
return true;
|
||
}
|
||
|
||
bool PlotView::SetLaneMeanFilterScroll(int laneIndex, float scroll)
|
||
{
|
||
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
|
||
{
|
||
return false;
|
||
}
|
||
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
|
||
const float clamped = std::max(0.0f, scroll);
|
||
if (std::fabs(lane.meanFilterScroll - clamped) < 0.5f)
|
||
{
|
||
return false;
|
||
}
|
||
// 滚动只影响面板内部像素,缓冲区/场景都不变(面板像素缓存按键失效即可)。
|
||
lane.meanFilterScroll = clamped;
|
||
return true;
|
||
}
|
||
|
||
// ============================ 「重采样」 ============================
|
||
|
||
bool PlotView::SignalDataInterval(const SignalRef& ref, double& maxInterval,
|
||
double& minInterval) const
|
||
{
|
||
maxInterval = 0.0;
|
||
minInterval = 0.0;
|
||
if (m_dataSet == nullptr)
|
||
{
|
||
return false;
|
||
}
|
||
const ResolvedSignal resolved = ResolveSignal(ref);
|
||
if (resolved.data == nullptr || resolved.axis == nullptr)
|
||
{
|
||
return false;
|
||
}
|
||
const TimeAxis& axis = *resolved.axis;
|
||
const SignalData& signal = *resolved.data;
|
||
const std::size_t count = std::min(axis.time.size(), signal.values.size());
|
||
bool havePrevious = false;
|
||
double previousTime = 0.0;
|
||
bool any = false;
|
||
for (std::size_t index = 0; index < count; ++index)
|
||
{
|
||
const double time = axis.time[index];
|
||
if (!std::isfinite(time) || !std::isfinite(signal.values[index]))
|
||
{
|
||
continue;
|
||
}
|
||
if (havePrevious)
|
||
{
|
||
const double gap = time - previousTime;
|
||
if (gap > 0.0)
|
||
{
|
||
if (!any)
|
||
{
|
||
maxInterval = gap;
|
||
minInterval = gap;
|
||
any = true;
|
||
}
|
||
else
|
||
{
|
||
maxInterval = std::max(maxInterval, gap);
|
||
minInterval = std::min(minInterval, gap);
|
||
}
|
||
}
|
||
}
|
||
previousTime = time;
|
||
havePrevious = true;
|
||
}
|
||
return any;
|
||
}
|
||
|
||
bool PlotView::SetLaneResample(int laneIndex, bool enabled)
|
||
{
|
||
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
|
||
{
|
||
return false;
|
||
}
|
||
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
|
||
if (lane.resample == enabled)
|
||
{
|
||
return false;
|
||
}
|
||
lane.resample = enabled;
|
||
if (enabled)
|
||
{
|
||
// 默认只勾选网格内**第一个可见信号**(与其它实验性功能一致)。
|
||
if (lane.resampleItems.empty())
|
||
{
|
||
for (const SignalRef& ref : lane.signals)
|
||
{
|
||
const ResolvedSignal resolved = ResolveSignal(ref);
|
||
if (resolved.data != nullptr && resolved.visible)
|
||
{
|
||
Lane::ResampleItem& item = lane.EnsureResampleItem(ref);
|
||
double maxInterval = 0.0;
|
||
double minInterval = 0.0;
|
||
if (SignalDataInterval(ref, maxInterval, minInterval))
|
||
{
|
||
item.maxInterval = maxInterval;
|
||
item.minInterval = minInterval;
|
||
item.interval = minInterval;
|
||
}
|
||
item.initialized = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
lane.resamplePanel.open = true;
|
||
}
|
||
else
|
||
{
|
||
lane.resamplePanel.open = false;
|
||
}
|
||
EnsureLaneLayersPrefetched();
|
||
return true;
|
||
}
|
||
|
||
bool PlotView::LaneResample(int laneIndex) const
|
||
{
|
||
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
|
||
{
|
||
return false;
|
||
}
|
||
return m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].resample;
|
||
}
|
||
|
||
bool PlotView::ToggleLaneResample(int laneIndex)
|
||
{
|
||
const bool enabled = !LaneResample(laneIndex);
|
||
SetLaneResample(laneIndex, enabled);
|
||
return enabled;
|
||
}
|
||
|
||
bool PlotView::ToggleLaneResampleSignal(int laneIndex, const SignalRef& ref)
|
||
{
|
||
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
|
||
{
|
||
return false;
|
||
}
|
||
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
|
||
if (lane.ResampleEnabled(ref))
|
||
{
|
||
lane.RemoveResampleItem(ref);
|
||
return false;
|
||
}
|
||
// 勾选时一次性算好最大 / 最小数据间隔(面板绘制只读缓存值)。
|
||
Lane::ResampleItem& item = lane.EnsureResampleItem(ref);
|
||
double maxInterval = 0.0;
|
||
double minInterval = 0.0;
|
||
if (SignalDataInterval(ref, maxInterval, minInterval))
|
||
{
|
||
item.maxInterval = maxInterval;
|
||
item.minInterval = minInterval;
|
||
item.interval = minInterval;
|
||
}
|
||
item.initialized = true;
|
||
return true;
|
||
}
|
||
|
||
bool PlotView::LaneResampleSignalEnabled(int laneIndex, const SignalRef& ref) const
|
||
{
|
||
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
|
||
{
|
||
return false;
|
||
}
|
||
return m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].ResampleEnabled(ref);
|
||
}
|
||
|
||
bool PlotView::SetLaneResampleScroll(int laneIndex, float scroll)
|
||
{
|
||
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
|
||
{
|
||
return false;
|
||
}
|
||
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
|
||
const float clamped = std::max(0.0f, scroll);
|
||
if (std::fabs(lane.resampleScroll - clamped) < 0.5f)
|
||
{
|
||
return false;
|
||
}
|
||
lane.resampleScroll = clamped;
|
||
return true;
|
||
}
|
||
|
||
bool PlotView::TakePendingResampleBuild(int& laneIndex, std::vector<ResampleBuild>& items)
|
||
{
|
||
if (m_pendingResampleLane < 0)
|
||
{
|
||
return false;
|
||
}
|
||
laneIndex = m_pendingResampleLane;
|
||
items = m_pendingResampleItems;
|
||
m_pendingResampleLane = -1;
|
||
m_pendingResampleItems.clear();
|
||
return true;
|
||
}
|
||
|
||
|
||
bool PlotView::SetLaneEdgeCenterScroll(int laneIndex, float scroll) {
|
||
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
|
||
{
|
||
return false;
|
||
}
|
||
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
|
||
const float clamped = std::max(0.0f, scroll);
|
||
if (std::fabs(lane.edgeCenterScroll - clamped) < 0.5f)
|
||
{
|
||
return false;
|
||
}
|
||
// 滚动只影响面板内部像素,缓冲区/场景都不变(面板像素缓存按键失效即可)。
|
||
lane.edgeCenterScroll = clamped;
|
||
return true;
|
||
}
|
||
|
||
bool PlotView::SetLaneWeakPeakScroll(int laneIndex, float scroll)
|
||
{
|
||
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
|
||
{
|
||
return false;
|
||
}
|
||
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
|
||
const float clamped = std::max(0.0f, scroll);
|
||
if (std::fabs(lane.weakPeakScroll - clamped) < 0.5f)
|
||
{
|
||
return false;
|
||
}
|
||
// 滚动只影响面板内部像素,缓冲区/场景都不变(面板像素缓存按键失效即可)。
|
||
lane.weakPeakScroll = clamped;
|
||
return true;
|
||
}
|
||
|
||
bool PlotView::SetLaneEdgeCountScroll(int laneIndex, float scroll)
|
||
{
|
||
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
|
||
{
|
||
return false;
|
||
}
|
||
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
|
||
const float clamped = std::max(0.0f, scroll);
|
||
if (std::fabs(lane.edgeCountScroll - clamped) < 0.5f)
|
||
{
|
||
return false;
|
||
}
|
||
// 滚动只影响面板内部像素,缓冲区/场景都不变(面板像素缓存按键失效即可)。
|
||
lane.edgeCountScroll = clamped;
|
||
return true;
|
||
}
|
||
|
||
bool PlotView::ToggleLaneFreeMarkerGroup(int laneIndex, const SignalRef& ref)
|
||
{
|
||
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
|
||
{
|
||
return false;
|
||
}
|
||
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
|
||
return lane.ToggleFreeMarkerGroupCollapsed(ref);
|
||
}
|
||
|
||
bool PlotView::SelectLaneFreeMarker(int laneIndex, int itemIndex)
|
||
{
|
||
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
|
||
{
|
||
return false;
|
||
}
|
||
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
|
||
// 越界一律视为“取消选中”(面板重建、条目被删之后调用方不必先做下界检查)。
|
||
const int clamped = (itemIndex >= 0 && static_cast<std::size_t>(itemIndex) < lane.freeMarkerItems.size())
|
||
? itemIndex
|
||
: -1;
|
||
if (lane.freeMarkerSelected == clamped)
|
||
{
|
||
return false;
|
||
}
|
||
lane.freeMarkerSelected = clamped;
|
||
return true;
|
||
}
|
||
|
||
bool PlotView::SetLanePanelCollapsed(int laneIndex, EdgePanelKind kind, bool collapsed)
|
||
{
|
||
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
|
||
{
|
||
return false;
|
||
}
|
||
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
|
||
LanePanelState* state = nullptr;
|
||
switch (kind)
|
||
{
|
||
case EdgePanelKind::COUNT:
|
||
state = &lane.edgePanel;
|
||
break;
|
||
case EdgePanelKind::CENTER:
|
||
state = &lane.edgeCenterPanel;
|
||
break;
|
||
case EdgePanelKind::MEAN:
|
||
state = &lane.meanFilterPanel;
|
||
break;
|
||
case EdgePanelKind::PEAK:
|
||
state = &lane.weakPeakPanel;
|
||
break;
|
||
case EdgePanelKind::MARK:
|
||
state = &lane.freeMarkerPanel;
|
||
break;
|
||
case EdgePanelKind::RESAMPLE:
|
||
state = &lane.resamplePanel;
|
||
break;
|
||
default:
|
||
return false;
|
||
}
|
||
if (state->collapsed == collapsed)
|
||
{
|
||
return false;
|
||
}
|
||
state->collapsed = collapsed;
|
||
return true;
|
||
}
|
||
|
||
bool PlotView::LanePanelCollapsed(int laneIndex, EdgePanelKind kind) const
|
||
{
|
||
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
|
||
{
|
||
return false;
|
||
}
|
||
const Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
|
||
switch (kind)
|
||
{
|
||
case EdgePanelKind::COUNT:
|
||
return lane.edgePanel.collapsed;
|
||
case EdgePanelKind::CENTER:
|
||
return lane.edgeCenterPanel.collapsed;
|
||
case EdgePanelKind::MEAN:
|
||
return lane.meanFilterPanel.collapsed;
|
||
case EdgePanelKind::PEAK:
|
||
return lane.weakPeakPanel.collapsed;
|
||
case EdgePanelKind::MARK:
|
||
return lane.freeMarkerPanel.collapsed;
|
||
case EdgePanelKind::RESAMPLE:
|
||
return lane.resamplePanel.collapsed;
|
||
default:
|
||
return false;
|
||
}
|
||
}
|
||
|
||
bool PlotView::ToggleLanePanelCollapsed(int laneIndex, EdgePanelKind kind)
|
||
{
|
||
const bool collapsed = !LanePanelCollapsed(laneIndex, kind);
|
||
SetLanePanelCollapsed(laneIndex, kind, collapsed);
|
||
return collapsed;
|
||
}
|
||
|
||
bool PlotView::MeanFilterValueAt(const TimeAxis& axis, const SignalData& signal, int points, int iterations,
|
||
double time, double& value) const
|
||
{
|
||
const int index = FindNearestSample(axis, time);
|
||
if (index < 0)
|
||
{
|
||
return false;
|
||
}
|
||
// ⚠️ **绝不能调用整条同步的 `MeanFilterCurveFor`**:光标读数在绘制路径上被调用,
|
||
// 整条曲线(千万级采样)同步计算会让界面停顿数秒(用户报告"程序被卡住很久")。
|
||
// 这里只取「需要的那一个下标 ± 保护带」的窗口,并在窗口很大时走后台异步计算
|
||
// (本帧取不到就返回 false,读数暂时不显示该曲线的取值)。
|
||
std::size_t spanBegin = 0;
|
||
const std::vector<float>* curve = CurveSpanFor(
|
||
DerivedCurveKind::MEAN_FILTER, signal, points, iterations, 0,
|
||
static_cast<std::size_t>(index), static_cast<std::size_t>(index) + 1, spanBegin, true);
|
||
if (curve == nullptr || static_cast<std::size_t>(index) < spanBegin ||
|
||
(static_cast<std::size_t>(index) - spanBegin) >= curve->size())
|
||
{
|
||
return false;
|
||
}
|
||
const float sampled = (*curve)[static_cast<std::size_t>(index) - spanBegin];
|
||
if (!std::isfinite(sampled))
|
||
{
|
||
return false;
|
||
}
|
||
value = static_cast<double>(sampled);
|
||
return true;
|
||
}
|
||
|
||
void PlotView::VisibleIndexRange(const TimeAxis& axis, std::size_t count, std::size_t& begin,
|
||
std::size_t& end, double timeShift) const
|
||
{
|
||
begin = 0;
|
||
end = count;
|
||
if (!axis.monotonic || count == 0)
|
||
{
|
||
return;
|
||
}
|
||
// 时间偏移:采样画在 `t + timeShift` 处,因此落在当前视界内的采样其源时刻区间要整体左移。
|
||
const std::vector<double>& time = axis.time;
|
||
begin = static_cast<std::size_t>(
|
||
std::lower_bound(time.begin(), time.end(), Tab().ViewMin() - timeShift) - time.begin());
|
||
if (begin > 0)
|
||
{
|
||
--begin;
|
||
}
|
||
end = static_cast<std::size_t>(
|
||
std::lower_bound(time.begin(), time.end(), Tab().ViewMax() - timeShift) - time.begin());
|
||
if (end < count)
|
||
{
|
||
++end;
|
||
}
|
||
end = std::max(end, begin);
|
||
begin = std::min(begin, count);
|
||
end = std::min(end, count);
|
||
}
|
||
|
||
const std::vector<float>* PlotView::CurveSpanFor(DerivedCurveKind kind, const SignalData& signal,
|
||
int points, int iterations, int amountPercent,
|
||
std::size_t begin, std::size_t end,
|
||
std::size_t& spanBegin,
|
||
bool allowAsyncCompute,
|
||
bool computeIfMissing) const
|
||
{
|
||
spanBegin = 0;
|
||
const std::size_t count = signal.values.size();
|
||
if (count == 0)
|
||
{
|
||
return nullptr;
|
||
}
|
||
begin = std::min(begin, count);
|
||
end = std::min(std::max(end, begin), count);
|
||
if (end <= begin)
|
||
{
|
||
return nullptr;
|
||
}
|
||
|
||
const int kindValue = static_cast<int>(kind);
|
||
const int safePoints = std::max(0, std::min(points, 1000000));
|
||
const int safeIterations = std::max(1, std::min(iterations, 1000));
|
||
const int safeLevel = std::max(kWeakPeakAmountMin, std::min(kWeakPeakAmountMax, amountPercent));
|
||
|
||
// 对齐到粗网格:轻微平移/缩放时可直接复用同一条窗口,避免每帧重算。
|
||
const std::size_t grid = std::max<std::size_t>(4096, (end - begin) / 4);
|
||
const std::size_t alignedBegin = (begin / grid) * grid;
|
||
const std::size_t alignedEnd = std::min(count, ((end + grid - 1) / grid) * grid);
|
||
|
||
CurveKey key;
|
||
key.signal = &signal;
|
||
key.kind = kindValue;
|
||
key.points = safePoints;
|
||
key.iterations = safeIterations;
|
||
key.amountPercent = safeLevel;
|
||
|
||
// 1) 已有覆盖请求区间的最新条目 → 直接复用(钉住,防止本帧被回收)。
|
||
if (const CurveEntry* hit = AcquireCoveringCurve(key, begin, end, spanBegin))
|
||
{
|
||
return &hit->values;
|
||
}
|
||
|
||
// 1b) 「只读」模式(光标取值胶囊):**绝不新建计算任务 / 条目**。
|
||
if (!computeIfMissing)
|
||
{
|
||
if (const CurveEntry* stale = AcquireNewestCurve(key, begin, end, spanBegin))
|
||
{
|
||
return &stale->values;
|
||
}
|
||
return nullptr;
|
||
}
|
||
|
||
// 1b) “只读”模式(光标取值胶囊):**绝不新建计算任务/条目**。
|
||
//
|
||
// ⚠️⚠️ 为什么必须这么做(用户 2026-11-02 反馈“拖光标时有明显卡顿”):
|
||
// 胶囊只为“光标所在的那两个采样”取值,请求窗口只有几个采样 —— 但缓存键里含窗口,
|
||
// 而窗口随光标每帧都在变 ⇒ 每帧都未命中 ⇒ 每帧同步计算并 `PublishCurve` 一次。
|
||
// 15 信号 × 3 种曲线 = 每帧 45 次发布,条目列表不断变长,`Acquire*` 的线性搜索
|
||
// 也随之变慢(实测拖动光标单帧 200ms,其中约 150ms 来自这 45 次重算 + 发布)。
|
||
// 现在:命中就取(绘制路径发布的窗口**带保护带**、覆盖整个可见区间,正常情况一定命中),
|
||
// 没命中就退到同 (信号, 种类) 的最新曲线,再没有就本帧不标注。
|
||
if (!computeIfMissing)
|
||
{
|
||
if (const CurveEntry* stale = AcquireNewestCurve(key, begin, end, spanBegin))
|
||
{
|
||
return &stale->values;
|
||
}
|
||
return nullptr;
|
||
}
|
||
|
||
// 2) 代价较大时交后台计算,本帧先用最新旧曲线顶替(双缓冲)。
|
||
// 这样绘制线程永远不做重计算,拖动滑块/光标/视界/缩放的反馈因此始终跟手。
|
||
//
|
||
// ⚠️ **超大信号 + 正在拖动参数:不要每帧都排一次"整条信号"的重算**。
|
||
// 预览曲线一律按整条信号计算(`[0, count]`,与「构建为新信号」逐位一致);
|
||
// 当信号极大(`VERY_BIG_DATA` 有 2576 万采样)时,每条曲线都要扫描上亿个采样,
|
||
// 后台线程会被**持续占满**(实测把可用内存带宽吃光),反过来把前台绘制拖慢
|
||
// (用户报告的"深放大下拖预览滑块非常非常卡"——实测前台每帧 14ms,而绘制本身只要 0.4ms)。
|
||
// 此时沿用旧曲线,等松手(不再处于拖拽)后再算一次最新的即可:反正整条信号的重算
|
||
// 本来也追不上滑块速度,拖动期间"冻结"与"每帧重排"在观感上没有区别,但后者会拖慢前台。
|
||
// 小信号(< `kHugeSignalPreviewSamples`)不受影响,仍保留"边拖边算"的实时预览。
|
||
if (count > kHugeSignalPreviewSamples && m_dragMode != PlotDragMode::NONE)
|
||
{
|
||
if (const CurveEntry* stale = AcquireNewestCurve(key, begin, end, spanBegin))
|
||
{
|
||
return &stale->values;
|
||
}
|
||
return nullptr;
|
||
}
|
||
|
||
// 计算窗口(含保护带):既用于同步计算,也用于判断是否值得交后台。
|
||
std::size_t computedBegin = 0;
|
||
std::size_t computedEnd = 0;
|
||
DerivedGuardWindow(kind, alignedBegin, alignedEnd, safePoints, safeIterations, count,
|
||
computedBegin, computedEnd);
|
||
if (computedEnd <= computedBegin)
|
||
{
|
||
return nullptr;
|
||
}
|
||
const std::size_t computeSamples = computedEnd - computedBegin;
|
||
if (allowAsyncCompute && computeSamples > kDerivedAsyncWindowThreshold)
|
||
{
|
||
ScheduleCurveJob(key, alignedBegin, alignedEnd, count);
|
||
if (const CurveEntry* stale = AcquireNewestCurve(key, begin, end, spanBegin))
|
||
{
|
||
return &stale->values;
|
||
}
|
||
return nullptr; // 尚无任何可显示曲线:本帧跳过该预览曲线(不阻塞绘制)
|
||
}
|
||
|
||
// 3) 代价很小或需要精确取值 → 同步计算并发布(代价可接受,且保证取值精确)。
|
||
// ⚠️ 同步路径也要先做内存判断:窗口虽小,但低内存设备上仍可能失败 ——
|
||
// 宁可这次不画预览,也不能抛 `bad_alloc` 把进程带走。
|
||
{
|
||
bool throttle = false; // 窗口 ≤ 门槛(65536 采样)→ 无需节流
|
||
if (!DerivedMemoryAllows(computeSamples, throttle))
|
||
{
|
||
return nullptr;
|
||
}
|
||
}
|
||
std::vector<float> values = ComputeDerivedWindow(signal, computedBegin, computedEnd, kindValue,
|
||
safePoints, safeIterations, safeLevel, nullptr,
|
||
false);
|
||
const CurveEntry* published =
|
||
PublishCurve(key, computedBegin, computedEnd, std::move(values), spanBegin);
|
||
return (published != nullptr) ? &published->values : nullptr;
|
||
}
|
||
|
||
void PlotView::DerivedGuardWindow(DerivedCurveKind kind, std::size_t alignedBegin,
|
||
std::size_t alignedEnd, int points, int iterations,
|
||
std::size_t count, std::size_t& begin, std::size_t& end) const
|
||
{
|
||
// 保护带取窗口长度的 1/8(夹在 4096~65536):弱峰调整的修正沿相邻极值向内传播,
|
||
// 1/8 个可见宽度足以覆盖真实信号的极值间距。
|
||
// 均值滤波必须额外**覆盖滤波半径**:N 阶共向外传播 X*N 个采样,否则窗口边界取值
|
||
// 会与「整条曲线」不同(边界上可用采样数更少)。
|
||
std::size_t guard =
|
||
std::clamp((alignedEnd - alignedBegin) / 8, std::size_t(4096), kDerivedCurveGuardMax);
|
||
if (kind == DerivedCurveKind::MEAN_FILTER)
|
||
{
|
||
const std::size_t reach =
|
||
static_cast<std::size_t>(points + 1) * static_cast<std::size_t>(iterations) + 64u;
|
||
guard = std::clamp(std::max(guard, reach), std::size_t(4096), kDerivedCurveGuardMax);
|
||
}
|
||
begin = (alignedBegin > guard) ? (alignedBegin - guard) : 0;
|
||
end = std::min(count, alignedEnd + guard);
|
||
}
|
||
|
||
const PlotView::CurveEntry* PlotView::AcquireCoveringCurve(const CurveKey& key, std::size_t begin,
|
||
std::size_t end,
|
||
std::size_t& spanBegin) const
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_curveMutex);
|
||
const CurveEntryPtr* best = nullptr;
|
||
for (const CurveEntryPtr& entry : m_curveEntries)
|
||
{
|
||
if (entry->key == key && entry->begin <= begin && entry->end >= end)
|
||
{
|
||
if (best == nullptr || entry->serial > (*best)->serial)
|
||
{
|
||
best = &entry;
|
||
}
|
||
}
|
||
}
|
||
if (best == nullptr)
|
||
{
|
||
return nullptr;
|
||
}
|
||
m_curvePins.push_back(*best); // 钉住:保证本帧内指针有效
|
||
spanBegin = (*best)->begin;
|
||
return best->get();
|
||
}
|
||
|
||
const PlotView::CurveEntry* PlotView::AcquireNewestCurve(const CurveKey& key, std::size_t begin,
|
||
std::size_t end,
|
||
std::size_t& spanBegin) const
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_curveMutex);
|
||
// ⚠️ **只按「信号 + 曲线种类」匹配**(忽略参数):拖动参数滑块时每帧参数都在变,
|
||
// 若要求键完全相同就永远找不到"上一帧的曲线",于是本帧无曲线可画 → **预览曲线闪烁**、
|
||
// 甚至短暂消失(用户报告的"滑动滑块时曲线消失一会儿又出现")。
|
||
// 旧曲线只是参数略旧,按采样下标映射位置依然完全正确,先顶上再等后台算好新曲线,
|
||
// 视觉上就是平滑过渡而不是闪烁。
|
||
//
|
||
// 优先挑「窗口覆盖所需区间」的:覆盖不到时曲线会缺一段(看起来像被切断)。
|
||
const CurveEntryPtr* newest = nullptr;
|
||
const CurveEntryPtr* newestCovering = nullptr;
|
||
for (const CurveEntryPtr& entry : m_curveEntries)
|
||
{
|
||
if (entry->key.signal != key.signal || entry->key.kind != key.kind)
|
||
{
|
||
continue;
|
||
}
|
||
if (newest == nullptr || entry->serial > (*newest)->serial)
|
||
{
|
||
newest = &entry;
|
||
}
|
||
if (entry->begin <= begin && entry->end >= end &&
|
||
(newestCovering == nullptr || entry->serial > (*newestCovering)->serial))
|
||
{
|
||
newestCovering = &entry;
|
||
}
|
||
}
|
||
const CurveEntryPtr* pick = (newestCovering != nullptr) ? newestCovering : newest;
|
||
if (pick == nullptr)
|
||
{
|
||
return nullptr;
|
||
}
|
||
m_curvePins.push_back(*pick);
|
||
spanBegin = (*pick)->begin;
|
||
return pick->get();
|
||
}
|
||
|
||
const PlotView::CurveEntry* PlotView::PublishCurve(const CurveKey& key, std::size_t begin,
|
||
std::size_t end, std::vector<float>&& values,
|
||
std::size_t& spanBegin) const
|
||
{
|
||
std::shared_ptr<CurveEntry> entry = std::make_shared<CurveEntry>();
|
||
entry->key = key;
|
||
entry->begin = begin;
|
||
entry->end = end;
|
||
entry->values = std::move(values);
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_curveMutex);
|
||
entry->serial = ++m_curveSerial;
|
||
m_curveEntries.push_back(entry);
|
||
}
|
||
m_curvePins.push_back(entry);
|
||
spanBegin = begin;
|
||
return entry.get();
|
||
}
|
||
|
||
bool PlotView::CurveJobIsStale(const CurveKey& key, std::uint64_t requestSerial) const
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_curveMutex);
|
||
const auto found = m_curveLatest.find(std::make_pair(key.signal, key.kind));
|
||
if (found == m_curveLatest.end())
|
||
{
|
||
return true; // 已经被清理(例如预览被关闭)→ 没有意义,直接作废
|
||
}
|
||
return found->second != requestSerial;
|
||
}
|
||
|
||
std::size_t PlotView::DerivedMinimumBytes(std::size_t samples)
|
||
{
|
||
// 输出曲线本体(float,占 1 份)+ 内部工作缓冲(「弱峰调整」实测 ≈34 B/采样,
|
||
// 含两个 float 工作缓冲与极值表)——取 48 B/采样作为「连一条曲线都装不下」的门槛。
|
||
return samples * 48u + (64u * 1024u * 1024u);
|
||
}
|
||
|
||
bool PlotView::DerivedMemoryAllows(std::size_t samples, bool& throttle) const
|
||
{
|
||
throttle = false;
|
||
const std::size_t minimum = DerivedMinimumBytes(samples);
|
||
const std::size_t available = AvailablePhysicalBytes();
|
||
if (available < minimum)
|
||
{
|
||
NotifyDerivedLowMemory();
|
||
return false;
|
||
}
|
||
// 低于「舒适线」(最小需求的 4 倍)→ 低内存模式:串行 + 节流,慢慢算完。
|
||
if (available < minimum * 4u)
|
||
{
|
||
throttle = true;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
void PlotView::NotifyDerivedLowMemory() const
|
||
{
|
||
if (m_curveLowMemoryWarned)
|
||
{
|
||
return; // 同一轮只提示一次,避免反复弹窗
|
||
}
|
||
m_curveLowMemoryWarned = true;
|
||
const HWND host = m_host;
|
||
if (host != nullptr)
|
||
{
|
||
PostMessageW(host, kMessagePlotLowMemory, 0, 0);
|
||
}
|
||
}
|
||
|
||
void PlotView::ScheduleCurveJob(const CurveKey& key, std::size_t alignedBegin,
|
||
std::size_t alignedEnd, std::size_t count) const
|
||
{
|
||
std::size_t windowBegin = 0;
|
||
std::size_t windowEnd = 0;
|
||
DerivedGuardWindow(static_cast<DerivedCurveKind>(key.kind), alignedBegin, alignedEnd, key.points,
|
||
key.iterations, count, windowBegin, windowEnd);
|
||
if (windowEnd <= windowBegin)
|
||
{
|
||
return;
|
||
}
|
||
// 「大任务」判据:窗口采样数超过大信号门槛(此时工作缓冲会到数百 MB 量级)。
|
||
const std::size_t windowSamples = windowEnd - windowBegin;
|
||
const bool huge = (windowSamples >= kHugeSignalPreviewSamples);
|
||
int hugeLimit = 1;
|
||
// ⚠️ **内存严重不足时根本不入队**(连一条曲线都装不下):告警一次并直接放弃本次计算。
|
||
// 这样“低内存设备”不会因为换页而把整机拖死,也不会抛 `bad_alloc` 逸出线程导致崩溃。
|
||
if (huge)
|
||
{
|
||
bool throttle = false;
|
||
if (!DerivedMemoryAllows(windowSamples, throttle))
|
||
{
|
||
return;
|
||
}
|
||
hugeLimit = throttle ? 1 : 2;
|
||
}
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_curveMutex);
|
||
// 新的计算请求 ⇒ 取消标志必须清零(此前可能因关闭预览/结构变化而被置位)。
|
||
m_curveCancelAll.store(false, std::memory_order_relaxed);
|
||
// ⚠️ **幂等**:边沿统计会在“曲线还没算好”时每帧请求一次整条曲线的计算。
|
||
// 如果每次都当作新请求(自增序号),正在算的那份就会被判为过时而退出 ——
|
||
// 于是永远算不完(请求 → 作废 → 重请求 的死循环)。
|
||
for (const CurveKey& running : m_curveRunningKeys)
|
||
{
|
||
if (running == key)
|
||
{
|
||
return; // 同一份曲线正在算,无需重复排队
|
||
}
|
||
}
|
||
for (const CurveJob& pending : m_curveJobs)
|
||
{
|
||
if (pending.key == key)
|
||
{
|
||
return; // 同一份曲线已在队列里等待
|
||
}
|
||
}
|
||
// 全局请求序号:这次请求之后,同一 (信号, 种类) 的旧任务就都过时了。
|
||
const std::uint64_t requestSerial = ++m_curveRequestSerial;
|
||
m_curveLatest[std::make_pair(key.signal, key.kind)] = requestSerial;
|
||
// 同一「信号 + 曲线种类」的**其它参数**的待算任务全部移除:参数已被取代,
|
||
// 正在计算的那个会在下一阶处看到自己已过时并**立即退出**。
|
||
for (auto iterator = m_curveJobs.begin(); iterator != m_curveJobs.end();)
|
||
{
|
||
if (iterator->key.signal == key.signal && iterator->key.kind == key.kind)
|
||
{
|
||
iterator = m_curveJobs.erase(iterator);
|
||
}
|
||
else
|
||
{
|
||
++iterator;
|
||
}
|
||
}
|
||
CurveJob job;
|
||
job.key = key;
|
||
job.windowBegin = windowBegin;
|
||
job.windowEnd = windowEnd;
|
||
job.generation = m_curveGeneration;
|
||
job.requestSerial = requestSerial;
|
||
job.huge = huge;
|
||
job.hugeLimit = hugeLimit;
|
||
m_curveJobs.push_back(job);
|
||
}
|
||
EnsureCurveWorker();
|
||
m_curveCv.notify_all();
|
||
}
|
||
|
||
void PlotView::EnsureCurveWorker() const
|
||
{
|
||
if (!m_curveWorkers.empty())
|
||
{
|
||
return;
|
||
}
|
||
// **多核并行**:后台线程池并发计算多条派生曲线(5 个网格各一条时,
|
||
// 串行要 5×368ms,并行只要约 1×368ms),预览曲线因此能很快出现。
|
||
// 线程数 = 硬件并发 - 1(给 UI/绘制线程留一个核),夹在 1~4。
|
||
unsigned int hardware = std::thread::hardware_concurrency();
|
||
int workers = static_cast<int>(hardware == 0 ? 2u : hardware) - 1;
|
||
workers = std::max(1, std::min(workers, 4));
|
||
m_curveWorkerStop = false;
|
||
m_curveWorkers.reserve(static_cast<std::size_t>(workers));
|
||
for (int index = 0; index < workers; ++index)
|
||
{
|
||
m_curveWorkers.emplace_back([this]() { CurveWorkerLoop(); });
|
||
}
|
||
}
|
||
|
||
void PlotView::StopCurveWorker() const
|
||
{
|
||
// 先请所有在算任务尽快退出(每阶 / 每 1M 采样检查一次),再等线程结束 ——
|
||
// 这是「过时任务立即清退并释放资源」的关键一步:千万级采样的一条曲线工作缓冲
|
||
// 可达数百 MB,必须尽早归还。
|
||
m_curveCancelAll.store(true, std::memory_order_relaxed);
|
||
if (m_curveWorkers.empty())
|
||
{
|
||
return;
|
||
}
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_curveMutex);
|
||
m_curveWorkerStop = true;
|
||
m_curveJobs.clear();
|
||
m_curveLatest.clear();
|
||
m_curveRunningKeys.clear();
|
||
}
|
||
m_curveCv.notify_all();
|
||
for (std::thread& worker : m_curveWorkers)
|
||
{
|
||
if (worker.joinable())
|
||
{
|
||
worker.join();
|
||
}
|
||
}
|
||
m_curveWorkers.clear();
|
||
m_curveHugeActive = 0;
|
||
}
|
||
|
||
void PlotView::CurveWorkerLoop() const
|
||
{
|
||
// 后台计算线程的优先级降到「低于正常」:派生曲线计算是**可延迟的后台工作**,
|
||
// 绝不能让它在拖动时与 UI / 绘制线程抢 CPU。降低优先级后,只要 UI 线程要运行,
|
||
// 系统就会优先调度 UI;计算只在 UI 空闲时占用 CPU,从而保证交互始终跟手
|
||
//(用户明确要求"无论如何 UI 及控件的响应需要是跟手的")。
|
||
SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_BELOW_NORMAL);
|
||
for (;;)
|
||
{
|
||
CurveJob job;
|
||
{
|
||
std::unique_lock<std::mutex> lock(m_curveMutex);
|
||
m_curveCv.wait(lock, [this]()
|
||
{ return m_curveWorkerStop || !m_curveJobs.empty(); });
|
||
if (m_curveJobs.empty())
|
||
{
|
||
return; // 仅在 stop 且队列为空时到达
|
||
}
|
||
job = m_curveJobs.front();
|
||
// ⚠️ **大窗口任务不能无限并发**:一条「整段信号」的弱峰调整工作缓冲可达数百 MB,
|
||
// 多个同时算就是 GB 级峰值(用户报告的 4.4GB)。上限由调度时的内存压力决定:
|
||
// 内存充足时允许 2 条(仍然很快,但峰值封顶);内存紧张时只允许 1 条。
|
||
if (job.huge && m_curveHugeActive >= std::max(1, job.hugeLimit))
|
||
{
|
||
m_curveCv.wait_for(lock, std::chrono::milliseconds(5));
|
||
continue;
|
||
}
|
||
m_curveJobs.pop_front();
|
||
if (job.generation != m_curveGeneration)
|
||
{
|
||
continue; // 代次已变化:丢弃过期任务
|
||
}
|
||
if (job.huge)
|
||
{
|
||
++m_curveHugeActive;
|
||
}
|
||
// 记录“正在计算的曲线键”:`ScheduleCurveJob` 靠它保持幂等
|
||
//(边沿统计会每帧请求同一份整条曲线,不能每次都当作新请求)。
|
||
m_curveRunningKeys.push_back(job.key);
|
||
}
|
||
|
||
// 计算在锁外进行:读取的采样在「结构未变」期间只读、且地址稳定
|
||
//(结构变化前宿主会先 `PauseDerivedAsync`)。
|
||
// ⚠️ 传入取消标志:一旦同一 (信号, 种类) 有了更新的请求、或发生了结构变化/关闭预览,
|
||
// 计算会在下一阶(或每 1M 采样)处**立即退出**,尽早归还工作缓冲。
|
||
bool throttle = false;
|
||
if (job.huge)
|
||
{
|
||
DerivedMemoryAllows(job.windowEnd - job.windowBegin, throttle);
|
||
}
|
||
std::vector<float> values = ComputeDerivedWindow(
|
||
*job.key.signal, job.windowBegin, job.windowEnd, job.key.kind, job.key.points,
|
||
job.key.iterations, job.key.amountPercent, &m_curveCancelAll, throttle);
|
||
|
||
if (job.huge)
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_curveMutex);
|
||
m_curveHugeActive = std::max(0, m_curveHugeActive - 1);
|
||
for (auto iterator = m_curveRunningKeys.begin(); iterator != m_curveRunningKeys.end();
|
||
++iterator)
|
||
{
|
||
if (*iterator == job.key)
|
||
{
|
||
m_curveRunningKeys.erase(iterator);
|
||
break;
|
||
}
|
||
}
|
||
m_curveCv.notify_all();
|
||
}
|
||
else
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_curveMutex);
|
||
for (auto iterator = m_curveRunningKeys.begin(); iterator != m_curveRunningKeys.end();
|
||
++iterator)
|
||
{
|
||
if (*iterator == job.key)
|
||
{
|
||
m_curveRunningKeys.erase(iterator);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ⚠️ **过时结果一律不发布,并立即可回收**:
|
||
// * 代次变化(结构变化)→ 丢弃;
|
||
// * 同一 (信号, 种类) 已有更新的请求 → 本结果参数已过时,丢弃(后台很快会有新的)。
|
||
// * 被取消(`m_curveCancelAll`)→ 曲线可能只算了一部分,绝不能发布。
|
||
if (m_curveCancelAll.load(std::memory_order_relaxed) ||
|
||
CurveJobIsStale(job.key, job.requestSerial))
|
||
{
|
||
continue;
|
||
}
|
||
{
|
||
std::lock_guard<std::mutex> lock(m_curveMutex);
|
||
if (job.generation != m_curveGeneration)
|
||
{
|
||
continue; // 计算期间发生了结构变化:丢弃,避免发布过期结果
|
||
}
|
||
if (values.empty())
|
||
{
|
||
continue;
|
||
}
|
||
std::shared_ptr<CurveEntry> entry = std::make_shared<CurveEntry>();
|
||
entry->key = job.key;
|
||
entry->begin = job.windowBegin;
|
||
entry->end = job.windowEnd;
|
||
entry->serial = ++m_curveSerial;
|
||
entry->values = std::move(values);
|
||
m_curveEntries.push_back(std::move(entry));
|
||
}
|
||
|
||
// 通知宿主重绘:下一帧会取到刚发布的曲线(双缓冲切换)。
|
||
m_curveSceneDirty.store(true, std::memory_order_relaxed);
|
||
const HWND host = m_host;
|
||
if (host != nullptr)
|
||
{
|
||
PostMessageW(host, kMessagePlotDerivedReady, 0, 0);
|
||
}
|
||
}
|
||
}
|
||
|
||
void PlotView::PruneCurveEntriesLocked() const
|
||
{
|
||
// 1) 去掉被「同键、更新、且完全覆盖」的条目取代的旧条目。
|
||
std::deque<CurveEntryPtr> kept;
|
||
for (const CurveEntryPtr& entry : m_curveEntries)
|
||
{
|
||
bool superseded = false;
|
||
for (const CurveEntryPtr& other : m_curveEntries)
|
||
{
|
||
if (other.get() == entry.get())
|
||
{
|
||
continue;
|
||
}
|
||
if (other->key == entry->key && other->serial > entry->serial &&
|
||
other->begin <= entry->begin && other->end >= entry->end)
|
||
{
|
||
superseded = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!superseded)
|
||
{
|
||
kept.push_back(entry);
|
||
}
|
||
}
|
||
m_curveEntries.swap(kept);
|
||
|
||
// 2) 条目数与总字节数双重约束(最旧优先淘汰)。
|
||
const auto bytes = [this]() -> std::size_t
|
||
{
|
||
std::size_t total = 0;
|
||
for (const CurveEntryPtr& entry : m_curveEntries)
|
||
{
|
||
total += entry->values.size() * sizeof(float);
|
||
}
|
||
return total;
|
||
};
|
||
while (!m_curveEntries.empty() &&
|
||
(m_curveEntries.size() > kCurveSpanCacheLimit || bytes() > kCurveSpanCacheMaxBytes))
|
||
{
|
||
m_curveEntries.pop_front();
|
||
}
|
||
}
|
||
|
||
const std::vector<float>& PlotView::MeanFilterCurveFor(const SignalData& signal, int points,
|
||
int iterations) const
|
||
{
|
||
const int radius = std::max(0, points);
|
||
const int stages = std::max(1, iterations);
|
||
for (const MeanCurveEntry& entry : m_meanCurveCache)
|
||
{
|
||
if (entry.signal == &signal && entry.points == radius && entry.iterations == stages)
|
||
{
|
||
return entry.values;
|
||
}
|
||
}
|
||
|
||
MeanCurveEntry entry;
|
||
entry.signal = &signal;
|
||
entry.points = radius;
|
||
entry.iterations = stages;
|
||
// 与「构建为新信号」共用同一实现(Utilities 内的自由函数),
|
||
// 因此预览曲线与构建出的采样值逐位一致。
|
||
ComputeMovingAverageIterated(signal.values, radius, stages, entry.values);
|
||
|
||
m_meanCurveCache.push_back(std::move(entry));
|
||
return m_meanCurveCache.back().values;
|
||
}
|
||
|
||
const std::vector<float>& PlotView::WeakPeakCurveFor(const SignalData& signal,
|
||
int amountPercent, int iterations) const
|
||
{
|
||
const int level = std::max(kWeakPeakAmountMin, std::min(kWeakPeakAmountMax, amountPercent));
|
||
const int stages = std::max(kWeakPeakIterationsMin, std::min(kWeakPeakIterationsMaxLimit, iterations));
|
||
for (const WeakPeakCurveEntry& entry : m_weakPeakCurveCache)
|
||
{
|
||
if (entry.signal == &signal && entry.amountPercent == level && entry.iterations == stages)
|
||
{
|
||
return entry.values;
|
||
}
|
||
}
|
||
|
||
WeakPeakCurveEntry entry;
|
||
entry.signal = &signal;
|
||
entry.amountPercent = level;
|
||
entry.iterations = stages;
|
||
// 与「构建为新信号」共用同一实现(Utilities 内的自由函数),
|
||
// 因此预览曲线与构建出的采样值逐位一致。
|
||
ComputeWeakPeakAdjust(signal.values, static_cast<double>(level) / 100.0, stages, entry.values);
|
||
|
||
m_weakPeakCurveCache.push_back(std::move(entry));
|
||
return m_weakPeakCurveCache.back().values;
|
||
}
|
||
|
||
bool PlotView::WeakPeakValueAt(const TimeAxis& axis, const SignalData& signal, int amountPercent,
|
||
int iterations, double time, double& value) const
|
||
{
|
||
const int index = FindNearestSample(axis, time);
|
||
if (index < 0)
|
||
{
|
||
return false;
|
||
}
|
||
std::size_t spanBegin = 0;
|
||
const std::vector<float>* curve = CurveSpanFor(
|
||
DerivedCurveKind::WEAK_PEAK, signal, 0, iterations, amountPercent,
|
||
static_cast<std::size_t>(index), static_cast<std::size_t>(index) + 1, spanBegin);
|
||
if (curve == nullptr || static_cast<std::size_t>(index) < spanBegin ||
|
||
(static_cast<std::size_t>(index) - spanBegin) >= curve->size())
|
||
{
|
||
return false;
|
||
}
|
||
const float sampled = (*curve)[static_cast<std::size_t>(index) - spanBegin];
|
||
if (!std::isfinite(sampled))
|
||
{
|
||
return false;
|
||
}
|
||
value = static_cast<double>(sampled);
|
||
return true;
|
||
}
|
||
|
||
void PlotView::RequestEdgeScanFrame() const
|
||
{
|
||
// 统计还没扫完时主动请宿主再送一帧 —— 否则用户不移动鼠标就不会有任何重绘,
|
||
// 预算也就永远不推进(用户报告:"面板过去很久都一直显示统计中,
|
||
// 只有当鼠标瞄到浮动面板上时才会显示统计好的值")。
|
||
// 与派生曲线就绪用同一条消息(宿主只失效绘图区、不置脏,代价极小)。
|
||
//
|
||
// ⚠️⚠️ **每个绘制帧最多 Post 一次**(用户 2026-11-02 报告“上百条信号时所有交互都卡”):
|
||
// 本函数在 `EdgeScanFor` 里按“未完成的状态”逐个调用,288 条信号时单帧会 Post 288 次!
|
||
// 大量 `WM_MSG`+重绘会把优先级更低的 `WM_TIMER` 饿死(分组折起动画因此永远结束不了),
|
||
// 同时把 CPU 占满、拖慢所有交互。用一个“本帧已请求”标志收敛成一次。
|
||
if (m_loadingBusy)
|
||
{
|
||
return; // 加载中:不推进、不自请求(否则加载进度被统计重绘拖慢)
|
||
}
|
||
if (m_edgeScanRequestFrame == m_edgeScanFrame)
|
||
{
|
||
return;
|
||
}
|
||
m_edgeScanRequestFrame = m_edgeScanFrame;
|
||
const HWND host = m_host;
|
||
if (host != nullptr)
|
||
{
|
||
PostMessageW(host, kMessagePlotDerivedReady, 0, 0);
|
||
}
|
||
}
|
||
|
||
std::size_t PlotView::EdgeScanStateLimit() const
|
||
{
|
||
// 每个信号最多两类累积状态(普通边沿计数 / 中心轴边沿计数),另留一点余量。
|
||
// ⚠️ 不能用“当前网格内信号数”:边缘面板可能同时开在多个网格上,
|
||
// 而这里用全仓可见信号总数更省心(多出来的容量只是几个几十 KB 的块和数组)。
|
||
std::size_t signalCount = 0;
|
||
if (m_dataSet != nullptr)
|
||
{
|
||
signalCount = m_dataSet->SignalCount();
|
||
}
|
||
const std::size_t scaled = signalCount * 2u + 16u;
|
||
return std::max(kEdgeScanStateLimit, scaled);
|
||
}
|
||
|
||
void PlotView::InvalidateEdgeScansFor(const SignalData* signal, bool centreOnly)
|
||
{ // ⚠️ **定向作废**:只有被改动的那个信号(并且只限需要的那一类)要重算,
|
||
// 其它信号的键没有变化、累计的块和依然有效 → 必须原样保留。
|
||
// 否则(过去用 `m_edgeScans.clear()`)会连**普通边沿计数**与**其它信号的中心轴统计**
|
||
// 一起丢掉,于是两个浮动面板里的每一个数字都要从头重新扫描:
|
||
// 用户报告“改变一个信号的中心轴参数,其它未改变参数的统计也重新计算了”。
|
||
if (signal == nullptr)
|
||
{
|
||
return;
|
||
}
|
||
m_edgeScans.erase(std::remove_if(m_edgeScans.begin(), m_edgeScans.end(),
|
||
[signal, centreOnly](const EdgeScanState& state)
|
||
{
|
||
if (state.signal != signal)
|
||
{
|
||
return false; // 别的信号:原样保留
|
||
}
|
||
return !centreOnly || state.centre;
|
||
}),
|
||
m_edgeScans.end());
|
||
}
|
||
|
||
void PlotView::EdgeScanFor(const TimeAxis& axis, const SignalData& signal, const SignalStyle& style,
|
||
bool centre, double threshold, int points, int iterations, bool hasRange,
|
||
double rangeMin, double rangeMax, std::size_t& rising, std::size_t& falling,
|
||
bool& complete) const
|
||
{
|
||
rising = 0;
|
||
falling = 0;
|
||
complete = false;
|
||
|
||
// ⚠️ **宿主正在加载文件时不推进**:加载线程的主循环不断 `UpdateWindow` 触发整帧绘制,
|
||
// 若这里继续扫,加载会被统计抢走 CPU(用户报告“统计没算完就无法开始加载”)。
|
||
// 加载完成后数据层结构变化会整体作废累积状态,本来就要重算。
|
||
if (m_loadingBusy)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const std::size_t count = signal.values.size();
|
||
if (count < 2)
|
||
{
|
||
complete = true;
|
||
return;
|
||
}
|
||
const bool monotonic = axis.monotonic;
|
||
// 非单调时间轴无法用「采样对下标区间」表达统计范围,只能逐点按时间过滤 →
|
||
// 此时区间必须进键(否则会混用别的区间结果)。
|
||
const bool rangeInKey = hasRange && !monotonic;
|
||
const std::uint64_t styleKey = EdgeStyleKey(style);
|
||
const int safePoints = std::max(0, std::min(points, 1000000));
|
||
const int safeIterations = std::max(1, std::min(iterations, 1000));
|
||
const bool mirrored = style.mirrorHorizontal;
|
||
const bool transform = (style.gain != 1.0f || style.absolute || style.mirrorVertical ||
|
||
style.offsetY != 0.0f);
|
||
|
||
// 请求的「采样对」区间 [pairFirst, pairLast](pair k = 采样 k-1 → k,k ≥ 1)。
|
||
// ⚠️ 只有**两个端点都落在区间内**的采样对才计入(非单调时由 deltaAt 过滤)。
|
||
std::size_t pairFirst = 1;
|
||
std::size_t pairLast = count - 1;
|
||
if (hasRange && monotonic)
|
||
{
|
||
std::size_t indexBegin = 0;
|
||
std::size_t indexEnd = 0;
|
||
EdgeIndexRange(axis, count, hasRange, rangeMin, rangeMax, indexBegin, indexEnd);
|
||
pairFirst = (indexBegin + 1 < count) ? (indexBegin + 1) : count;
|
||
pairLast = (indexEnd > 0) ? (indexEnd - 1) : 0;
|
||
if (pairLast < pairFirst)
|
||
{
|
||
complete = true;
|
||
return;
|
||
}
|
||
}
|
||
|
||
// ---- 定位累积状态 -------------------------------------------------------
|
||
EdgeScanState* state = nullptr;
|
||
for (EdgeScanState& candidate : m_edgeScans)
|
||
{
|
||
if (candidate.axis == &axis && candidate.signal == &signal && candidate.centre == centre &&
|
||
candidate.styleKey == styleKey && candidate.points == safePoints &&
|
||
candidate.iterations == safeIterations && candidate.hasRange == rangeInKey &&
|
||
(!rangeInKey || (candidate.rangeMin == rangeMin && candidate.rangeMax == rangeMax)))
|
||
{
|
||
state = &candidate;
|
||
break;
|
||
}
|
||
}
|
||
if (state == nullptr)
|
||
{
|
||
// ⚠️⚠️ **累积状态上限必须随“信号数”缩放**(用户 2026-11-02 报告:288 条信号合并到
|
||
// 同一网格、开启「边沿计数」后**全部停在“统计中…”**且永不更新):
|
||
// 旧上限固定 64,而面板会在一帧内为**每条信号**各创建一个状态(288 条 > 64)⇒
|
||
// 新建状态时总是把队首(尚未扫完的)淘汰 ⇒ 没有任何状态能活到扫完 ⇒ 永远重头开始。
|
||
// 每个信号最多两类状态(普通 / 中心轴),因此上限取 `2×信号数 + 余量`。
|
||
const std::size_t stateLimit = EdgeScanStateLimit();
|
||
if (m_edgeScans.size() >= stateLimit)
|
||
{
|
||
// ⚠️ 超限时**优先淘汰「已扫完」的最旧状态**(它重算很快、而且通常已经不需要再变),
|
||
// 绝不淘汰正在推进的那个 —— 否则其它信号会被迫从头重扫
|
||
//(那正是“改一个参数、其它几个信号也重新统计”这类现象)。
|
||
std::size_t victim = 0;
|
||
bool foundVictim = false;
|
||
for (std::size_t index = 0; index < m_edgeScans.size(); ++index)
|
||
{
|
||
if (m_edgeScans[index].complete)
|
||
{
|
||
victim = index;
|
||
foundVictim = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!foundVictim)
|
||
{
|
||
victim = 0;
|
||
}
|
||
m_edgeScans.erase(m_edgeScans.begin() + static_cast<std::ptrdiff_t>(victim));
|
||
}
|
||
EdgeScanState created;
|
||
created.axis = &axis;
|
||
created.signal = &signal;
|
||
created.centre = centre;
|
||
created.styleKey = styleKey;
|
||
created.points = safePoints;
|
||
created.iterations = safeIterations;
|
||
created.hasRange = rangeInKey;
|
||
created.rangeMin = rangeMin;
|
||
created.rangeMax = rangeMax;
|
||
created.threshold = threshold;
|
||
const std::size_t blocks = (count - 1) / kEdgeBlockSamples + 2;
|
||
created.blockRising.assign(blocks, 0);
|
||
created.blockFalling.assign(blocks, 0);
|
||
created.justReset = true;
|
||
m_edgeScans.push_back(std::move(created));
|
||
state = &m_edgeScans.back();
|
||
++m_edgeScanRevision; // 新状态:让面板重算(可能刚从“统计中…”切换)
|
||
}
|
||
else if (!centre && state->threshold != threshold)
|
||
{
|
||
// 阈值变了(横向光标正在拖动):**就地重新开始**,不新建条目(避免拖动时状态爆炸)。
|
||
// ⚠️ 本 tick 不展示部分值(`justReset`):部分计数每帧都会被重置,
|
||
// 显示出来就是"错误且不更新的数字"(用户报告过这个现象)。
|
||
state->threshold = threshold;
|
||
state->scannedPair = 1;
|
||
state->rising = 0;
|
||
state->falling = 0;
|
||
state->complete = false;
|
||
state->justReset = true;
|
||
std::fill(state->blockRising.begin(), state->blockRising.end(), 0);
|
||
std::fill(state->blockFalling.begin(), state->blockFalling.end(), 0);
|
||
++m_edgeScanRevision;
|
||
}
|
||
|
||
// ---- 中心轴:**就地**计算所需的那一小段均值滤波曲线 ---------------------
|
||
// ⚠️ 过去这里用 `AcquireCoveringCurve`/`ScheduleCurveJob` 拿**整条**曲线,带来两个问题:
|
||
// 1. 千万级信号的一条曲线 ≈103MB,多条信号叠加会超出曲线缓存预算(768MB)→ 条目被
|
||
// 淘汰后该信号被判为“曲线未就绪”,界面显示“统计中…”并从头重扫 ——
|
||
// **偶发**(取决于当时缓存里积了多少条目,用户报告“改一个参数其它几个信号也重新统计”);
|
||
// 2. 几个信号的统计通过这块共享缓存互相牵连。
|
||
// 现在只算「当前扫描块 ± 滤波半径」:`points * iterations` 就足以让块内取值与整条曲线
|
||
// 完全一致(迭代 N 次、半径 X 的均值滤波最多向两侧传播 X*N 个采样),内存只有几十 KB,
|
||
// 且与其它信号、其它曲线彻底解耦。
|
||
std::size_t localReach = 0;
|
||
if (centre)
|
||
{
|
||
localReach = static_cast<std::size_t>(safePoints) * static_cast<std::size_t>(safeIterations) + 2u;
|
||
localReach = std::min(localReach, kDerivedCurveGuardMax);
|
||
}
|
||
const std::vector<double>& values = signal.values;
|
||
const bool filterByTime = hasRange && !monotonic;
|
||
|
||
/** 把显示下标区间映射为源下标区间(水平镜像时头尾对调)。 */
|
||
const auto toSourceRange = [&](std::size_t displayFirst, std::size_t displayLast,
|
||
std::size_t& srcFirst, std::size_t& srcLast)
|
||
{
|
||
if (mirrored)
|
||
{
|
||
srcFirst = (count - 1) - displayLast;
|
||
srcLast = (count - 1) - displayFirst;
|
||
}
|
||
else
|
||
{
|
||
srcFirst = displayFirst;
|
||
srcLast = displayLast;
|
||
}
|
||
};
|
||
/** 确保本地曲线覆盖显示下标区间 [displayFirst, displayLast](含端点)。 */
|
||
const auto ensureLocalCurve = [&](std::size_t displayFirst, std::size_t displayLast) -> bool
|
||
{
|
||
if (!centre)
|
||
{
|
||
return true;
|
||
}
|
||
std::size_t srcFirst = 0;
|
||
std::size_t srcLast = 0;
|
||
toSourceRange(displayFirst, displayLast, srcFirst, srcLast);
|
||
if (state->localValid && state->localBegin <= srcFirst && state->localEnd > srcLast)
|
||
{
|
||
return true;
|
||
}
|
||
const std::size_t windowBegin = (srcFirst > localReach) ? (srcFirst - localReach) : 0;
|
||
std::size_t windowEnd = std::min(count, srcLast + 1u + localReach);
|
||
// 至少覆盖 8 个扫描块:迭代均值滤波每算一次窗口要 O(窗口 × 迭代次数),
|
||
// 只覆盖一个块会让参数较大时每块都重算一遍(摊薄到 8 个块上就便宜得多)。
|
||
const std::size_t minSpan = kEdgeBlockSamples * 8u;
|
||
windowEnd = std::min(count, std::max(windowEnd, windowBegin + minSpan));
|
||
if (windowEnd <= windowBegin)
|
||
{
|
||
return false;
|
||
}
|
||
std::vector<float> local =
|
||
ComputeDerivedWindow(signal, windowBegin, windowEnd,
|
||
static_cast<int>(DerivedCurveKind::MEAN_FILTER), safePoints,
|
||
safeIterations, 0, nullptr, false);
|
||
if (local.size() != (windowEnd - windowBegin))
|
||
{
|
||
++m_edgeScanLocalCurveFails;
|
||
return false; // 计算失败(例如内存不足):本帧不推进,下帧重试
|
||
}
|
||
state->localCurve = std::move(local);
|
||
state->localBegin = windowBegin;
|
||
state->localEnd = windowEnd;
|
||
state->localValid = true;
|
||
// 本地窗口的计算成本也计入预算(按「采样数 × 迭代次数」折算),避免一帧算太多。
|
||
const std::size_t cost =
|
||
(windowEnd - windowBegin) * static_cast<std::size_t>(safeIterations);
|
||
m_edgeScanBudget -= std::min(m_edgeScanBudget, cost);
|
||
return true;
|
||
};
|
||
|
||
// 判定基准:**绘制后**的值相对阈值 / 中心曲线的偏差(区间之外 / 缺失值 → NaN)。
|
||
const auto deltaAt = [&](std::size_t index) -> double
|
||
{
|
||
const double nan = std::numeric_limits<double>::quiet_NaN();
|
||
if (index >= count)
|
||
{
|
||
return nan;
|
||
}
|
||
if (filterByTime && index < axis.time.size())
|
||
{
|
||
const double time = axis.time[index];
|
||
if (time < rangeMin || time > rangeMax)
|
||
{
|
||
return nan;
|
||
}
|
||
}
|
||
const std::size_t sourceIndex = mirrored ? ((count - 1) - index) : index;
|
||
if (sourceIndex >= count)
|
||
{
|
||
return nan;
|
||
}
|
||
const double raw = values[sourceIndex];
|
||
if (!std::isfinite(raw))
|
||
{
|
||
return nan;
|
||
}
|
||
const double drawn = transform ? ApplyDrawTransform(style, raw) : raw;
|
||
if (!std::isfinite(drawn))
|
||
{
|
||
return nan;
|
||
}
|
||
if (!centre)
|
||
{
|
||
return drawn - threshold;
|
||
}
|
||
if (!state->localValid || sourceIndex < state->localBegin ||
|
||
sourceIndex >= state->localEnd)
|
||
{
|
||
return nan;
|
||
}
|
||
const float centerSample = state->localCurve[sourceIndex - state->localBegin];
|
||
if (!std::isfinite(centerSample))
|
||
{
|
||
return nan;
|
||
}
|
||
const double drawnCenter =
|
||
transform ? ApplyDrawTransform(style, static_cast<double>(centerSample))
|
||
: static_cast<double>(centerSample);
|
||
return std::isfinite(drawnCenter) ? (drawn - drawnCenter) : nan;
|
||
};
|
||
|
||
// ---- 推进(受「每帧总量 + 每状态配额」双重限制)--------------------------
|
||
// ⚠️ 预算在 `Paint` 开头**按帧**重置(不再按 `GetTickCount64()` 分桶):系统时钟
|
||
// 粒度约 15.6ms,帧率一高就会在同一 tick 内连画多帧而完全不推进 —— 用户报告的
|
||
// “按住横向光标不动时统计卡在统计中”“回车确认参数后不移动鼠标就一直停在部分值”
|
||
// 就是这个原因。
|
||
// ⚠️ 同时**每个状态各自持有配额**:否则推进总从列表第一个状态扫起,多条信号时
|
||
// 只有第一条有进展、其余长期显示“统计中…”(用户报告“很多项都停在统计中不更新”)。
|
||
const bool suppressPartial = state->justReset && !state->complete;
|
||
if (!state->complete && !state->blockRising.empty())
|
||
{
|
||
// ⚠️ 分母只数**还没扫完**的状态:已完成的状态不再消耗配额,若把它们也算进分母,
|
||
// 上百条短信号里夹着几条千万级长信号时,长信号每帧只能分到 1/300 的预算
|
||
// (实测要 20 分钟才出结果)。按“未完成数”分摊后,短信号一完成就把预算让出来。
|
||
std::size_t activeStates = 0;
|
||
for (const EdgeScanState& candidate : m_edgeScans)
|
||
{
|
||
if (!candidate.complete)
|
||
{
|
||
++activeStates;
|
||
}
|
||
}
|
||
activeStates = std::max<std::size_t>(1, activeStates);
|
||
const std::size_t share = std::max<std::size_t>(kEdgeScanFairShare, activeStates);
|
||
// ⚠️ **不要给每状态一个固定下限**(旧实现是 `max(60000, budget/share)`):
|
||
// 状态数一多,下限就让**前几个状态**(恰好是网格里最前面的信号)吃掉整帧预算,
|
||
// 后面的信号永远分不到配额 ⇒ 一直显示“统计中…”(用户 2026-11-02 报告的
|
||
// 288 信号场景)。改为纯按未完成状态数平分本帧预算(至多 1)。
|
||
const std::size_t perState = std::max<std::size_t>(1, m_edgeScanBudget / share);
|
||
if (state->pumpedFrame != m_edgeScanFrame)
|
||
{
|
||
state->pumpedFrame = m_edgeScanFrame;
|
||
state->pumpedPairs = 0;
|
||
}
|
||
std::size_t quota = (perState > state->pumpedPairs) ? (perState - state->pumpedPairs) : 0;
|
||
quota = std::min(quota, m_edgeScanBudget);
|
||
if (quota > 0)
|
||
{
|
||
state->justReset = false;
|
||
// 快路径:时间单调、无水平镜像、无「调整」变换、非中心轴 ⇒ 直接比较采样值与阈值。
|
||
// (NaN 与任何数比较都为 false ⇒ 天然满足"NaN 不参与且打断连续"的语义。)
|
||
// 绝大多数信号都走这条,单次预算的耗时因此从 ~18ms 降到 ~2ms。
|
||
const bool plainFast = monotonic && !mirrored && !transform && !centre;
|
||
std::size_t scanned = 0;
|
||
std::size_t pair = state->scannedPair;
|
||
std::size_t block = (pair > 0) ? ((pair - 1) / kEdgeBlockSamples) : 0;
|
||
/** @brief 本帧的**时间预算**是否已用完(每块检查一次,QPC 开销可忽略)。 */
|
||
const auto timeUp = [this]() -> bool
|
||
{
|
||
if (m_edgeScanDeadline <= 0)
|
||
{
|
||
return false;
|
||
}
|
||
LARGE_INTEGER now = {};
|
||
QueryPerformanceCounter(&now);
|
||
return now.QuadPart >= m_edgeScanDeadline;
|
||
};
|
||
while (pair < count && scanned < quota && m_edgeScanBudget > 0)
|
||
{
|
||
const std::size_t blockFirst = block * kEdgeBlockSamples + 1;
|
||
const std::size_t blockLast = std::min(count - 1, (block + 1) * kEdgeBlockSamples);
|
||
const std::size_t from = std::max(pair, blockFirst);
|
||
const std::size_t avail = std::min(quota - scanned, m_edgeScanBudget);
|
||
const std::size_t to = std::min(blockLast, from + avail - 1);
|
||
if (to < from)
|
||
{
|
||
break;
|
||
}
|
||
// 中心轴:先确保本地区间曲线覆盖本块(含两端各一个采样点,供边界比较)。
|
||
if (!ensureLocalCurve(from - 1, to))
|
||
{
|
||
break; // 算不出来(内存不足等):本帧到此为止,下帧重试
|
||
}
|
||
std::size_t localRising = 0;
|
||
std::size_t localFalling = 0;
|
||
if (plainFast)
|
||
{
|
||
double previous = values[from - 1] - threshold;
|
||
for (std::size_t index = from; index <= to; ++index)
|
||
{
|
||
const double current = values[index] - threshold;
|
||
if (previous < 0.0 && current >= 0.0)
|
||
{
|
||
++localRising;
|
||
}
|
||
else if (previous > 0.0 && current <= 0.0)
|
||
{
|
||
++localFalling;
|
||
}
|
||
previous = current;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
double previous = deltaAt(from - 1);
|
||
for (std::size_t index = from; index <= to; ++index)
|
||
{
|
||
const double current = deltaAt(index);
|
||
if (std::isfinite(previous) && std::isfinite(current))
|
||
{
|
||
if (previous < 0.0 && current >= 0.0)
|
||
{
|
||
++localRising;
|
||
}
|
||
else if (previous > 0.0 && current <= 0.0)
|
||
{
|
||
++localFalling;
|
||
}
|
||
}
|
||
previous = current;
|
||
}
|
||
}
|
||
if (block < state->blockRising.size())
|
||
{
|
||
state->blockRising[block] += localRising;
|
||
state->blockFalling[block] += localFalling;
|
||
}
|
||
state->rising += localRising;
|
||
state->falling += localFalling;
|
||
scanned += (to - from + 1);
|
||
pair = to + 1;
|
||
if (to == blockLast)
|
||
{
|
||
++block;
|
||
}
|
||
if (timeUp())
|
||
{
|
||
break; // 本帧的时间预算用完:剩下的帧接着扫
|
||
}
|
||
}
|
||
state->scannedPair = pair;
|
||
state->complete = (pair >= count);
|
||
if (state->complete)
|
||
{
|
||
// 扫完后**释放**本地曲线(区间查询需要时会按需重算)——
|
||
// 否则多信号网格下每个状态都要常驻几十 KB~几 MB。
|
||
state->localCurve.clear();
|
||
state->localCurve.shrink_to_fit();
|
||
state->localBegin = 0;
|
||
state->localEnd = 0;
|
||
state->localValid = false;
|
||
}
|
||
state->pumpedPairs += scanned;
|
||
m_edgeScanBudget -= std::min(m_edgeScanBudget, scanned);
|
||
if (scanned > 0)
|
||
{
|
||
// 数字(或“是否扫完”)变了 ⇒ 面板缓存键要跟着变,否则会一直贴旧像素。
|
||
++m_edgeScanRevision;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- 汇总请求区间 -------------------------------------------------------
|
||
// 内部整块用块和相加,只有两端边界块(≤ 2 块)需要就地精确扫描
|
||
// ⇒ 拖动**时间**光标时单帧代价与区间长度无关。
|
||
if (!suppressPartial)
|
||
{
|
||
const std::size_t limit =
|
||
(state->scannedPair > 0) ? std::min(pairLast, state->scannedPair - 1) : 0;
|
||
if (limit >= pairFirst)
|
||
{
|
||
const std::size_t firstBlock = (pairFirst - 1) / kEdgeBlockSamples;
|
||
const std::size_t lastBlock = (limit - 1) / kEdgeBlockSamples;
|
||
bool boundaryOk = true;
|
||
for (std::size_t block = firstBlock; block <= lastBlock && boundaryOk; ++block)
|
||
{
|
||
const std::size_t blockFirst = block * kEdgeBlockSamples + 1;
|
||
const std::size_t blockLast = std::min(count - 1, (block + 1) * kEdgeBlockSamples);
|
||
const std::size_t low = std::max(pairFirst, blockFirst);
|
||
const std::size_t high = std::min(limit, blockLast);
|
||
const bool wholeBlock = (low == blockFirst) && (high == blockLast) &&
|
||
(state->scannedPair > blockLast) &&
|
||
(block < state->blockRising.size());
|
||
if (wholeBlock)
|
||
{
|
||
rising += state->blockRising[block];
|
||
falling += state->blockFalling[block];
|
||
continue;
|
||
}
|
||
// 边界块 / 尚未扫完的块:就地精确扫描这一小段(≤ 1 个块)。
|
||
if (!ensureLocalCurve(low - 1, high))
|
||
{
|
||
boundaryOk = false; // 中心轴曲线算不出来:本帧不展示(下帧重试)
|
||
break;
|
||
}
|
||
double previous = deltaAt(low - 1);
|
||
for (std::size_t index = low; index <= high; ++index)
|
||
{
|
||
const double current = deltaAt(index);
|
||
if (std::isfinite(previous) && std::isfinite(current))
|
||
{
|
||
if (previous < 0.0 && current >= 0.0)
|
||
{
|
||
++rising;
|
||
}
|
||
else if (previous > 0.0 && current <= 0.0)
|
||
{
|
||
++falling;
|
||
}
|
||
}
|
||
previous = current;
|
||
}
|
||
}
|
||
// 边界块扫描失败(中心轴曲线算不出来)时不展示结果:保持 complete=false。
|
||
complete = boundaryOk && (pairLast < state->scannedPair);
|
||
}
|
||
else
|
||
{
|
||
complete = (pairLast < state->scannedPair);
|
||
}
|
||
}
|
||
if (!complete)
|
||
{
|
||
RequestEdgeScanFrame();
|
||
}
|
||
}
|
||
|
||
void PlotView::EdgeCountsFor(const TimeAxis& axis, const SignalData& signal, const SignalStyle& style,
|
||
double edgeValue, bool hasRange, double rangeMin, double rangeMax,
|
||
std::size_t& rising, std::size_t& falling, bool& complete) const
|
||
{
|
||
EdgeScanFor(axis, signal, style, false, edgeValue, 0, 1, hasRange, rangeMin, rangeMax, rising,
|
||
falling, complete);
|
||
}
|
||
|
||
void PlotView::EdgeCenterCountsFor(const TimeAxis& axis, const SignalData& signal,
|
||
const SignalStyle& style, int points, int iterations,
|
||
bool hasRange, double rangeMin, double rangeMax,
|
||
std::size_t& rising, std::size_t& falling, bool& complete) const
|
||
{
|
||
// 与普通边沿计数完全共用同一套「逐块累积 + 每 tick 预算推进」机制;
|
||
// 区别只在判定基准(信号 − 中心平均值曲线)与所需曲线(均值滤波,需整条)。
|
||
EdgeScanFor(axis, signal, style, true, 0.0, points, iterations, hasRange, rangeMin, rangeMax,
|
||
rising, falling, complete);
|
||
}
|
||
|
||
|
||
bool PlotView::EdgeRangeActive() const
|
||
{
|
||
return m_tab != nullptr && Tab().CursorMode() == PlotCursorMode::DOUBLE;
|
||
}
|
||
|
||
double PlotView::EdgeRangeMin() const
|
||
{
|
||
if (m_tab == nullptr)
|
||
{
|
||
return 0.0;
|
||
}
|
||
// 左右光标可能被拖动交换:始终取较小者为区间左端。
|
||
return std::min(Tab().CursorTime(), Tab().CursorTimeAlt());
|
||
}
|
||
|
||
double PlotView::EdgeRangeMax() const
|
||
{
|
||
if (m_tab == nullptr)
|
||
{
|
||
return 0.0;
|
||
}
|
||
return std::max(Tab().CursorTime(), Tab().CursorTimeAlt());
|
||
}
|
||
|
||
std::wstring PlotView::EdgeRangeSuffix() const
|
||
{
|
||
return EdgeRangeActive() ? std::wstring(L"(光标区间内)") : std::wstring();
|
||
}
|
||
|
||
bool PlotView::ToggleLaneAutoFitToSelection(int laneIndex)
|
||
{
|
||
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
|
||
{
|
||
return false;
|
||
}
|
||
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
|
||
lane.fitYToSelection = !lane.fitYToSelection;
|
||
if (lane.fitYToSelection)
|
||
{
|
||
// 打开时立即按当前视图自适应一次,否则当前量程不合适时看不到任何变化。
|
||
AutoRangeLaneToView(laneIndex);
|
||
}
|
||
return lane.fitYToSelection;
|
||
}
|
||
|
||
void PlotView::ApplyAutoFitToSelectionNow(int laneIndex)
|
||
{
|
||
if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
|
||
{
|
||
return;
|
||
}
|
||
if (!m_tab->Lanes()[static_cast<std::size_t>(laneIndex)].fitYToSelection)
|
||
{
|
||
return;
|
||
}
|
||
AutoRangeLaneToView(laneIndex);
|
||
}
|
||
|
||
void PlotView::ApplyAllAutoFitToSelectionNow()
|
||
{
|
||
if (m_tab == nullptr)
|
||
{
|
||
return;
|
||
}
|
||
const std::size_t count = m_tab->Lanes().size();
|
||
for (std::size_t index = 0; index < count; ++index)
|
||
{
|
||
ApplyAutoFitToSelectionNow(static_cast<int>(index));
|
||
}
|
||
}
|
||
|
||
bool PlotView::SetAllLanesAutoFitToSelection(bool enabled, bool applyNow)
|
||
{
|
||
if (m_tab == nullptr)
|
||
{
|
||
return false;
|
||
}
|
||
bool changed = false;
|
||
for (Lane& lane : m_tab->Lanes())
|
||
{
|
||
if (lane.fitYToSelection != enabled)
|
||
{
|
||
lane.fitYToSelection = enabled;
|
||
changed = true;
|
||
}
|
||
}
|
||
if (enabled && applyNow)
|
||
{
|
||
// 全局打开时立刻让每个网格都自适应一次,保证界面立刻反映开关状态。
|
||
ApplyAllAutoFitToSelectionNow();
|
||
}
|
||
return changed;
|
||
}
|
||
|
||
bool PlotView::AutoRangeLaneToView(int laneIndex)
|
||
{
|
||
if (m_tab == nullptr || m_dataSet == nullptr)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
const std::vector<Lane>& lanes = m_tab->Lanes();
|
||
if (laneIndex < 0 || laneIndex >= static_cast<int>(lanes.size()))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
const double viewMin = Tab().ViewMin();
|
||
const double viewMax = Tab().ViewMax();
|
||
if (!(viewMax > viewMin))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
// 只统计时间轴当前可见区间内的采样点,因此放大局部波形后可以快速把该段铺满网格。
|
||
double low = std::numeric_limits<double>::infinity();
|
||
double high = -std::numeric_limits<double>::infinity();
|
||
|
||
for (const SignalRef& ref : lanes[static_cast<std::size_t>(laneIndex)].signals)
|
||
{
|
||
if (!Tab().IsVisible(ref))
|
||
{
|
||
continue;
|
||
}
|
||
const SignalData* signal = m_dataSet->Resolve(ref);
|
||
const TimeAxis* axis = m_dataSet->Axis(ref);
|
||
if (signal == nullptr || axis == nullptr)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
const std::size_t count = std::min(axis->time.size(), signal->values.size());
|
||
const SignalStyle* style = m_dataSet->Style(ref);
|
||
// 时间偏移:可见采样对应的源时刻区间要整体左移(画在 t + shift 处)。
|
||
const double timeShift = (style != nullptr) ? style->offsetTime : 0.0;
|
||
const double sourceMin = viewMin - timeShift;
|
||
const double sourceMax = viewMax - timeShift;
|
||
std::size_t begin = 0;
|
||
std::size_t end = count;
|
||
if (axis->monotonic)
|
||
{
|
||
// 时间单调时直接二分定位区间,百万级采样也只需扫过可见段。
|
||
begin = static_cast<std::size_t>(
|
||
std::lower_bound(axis->time.begin(), axis->time.end(), sourceMin) - axis->time.begin());
|
||
if (begin > 0)
|
||
{
|
||
--begin;
|
||
}
|
||
end = static_cast<std::size_t>(
|
||
std::lower_bound(axis->time.begin(), axis->time.end(), sourceMax) - axis->time.begin());
|
||
if (end < count)
|
||
{
|
||
++end;
|
||
}
|
||
}
|
||
// ⚠️ 二分作用在**整条** `axis->time` 上,而可访问的采样数是 `count =
|
||
// min(时间轴长度, values 长度)`。分组的时间轴比某条信号长时(多信号共轴、
|
||
// 有的信号短),`begin/end` 会超出 `values` 的范围 ⇒ 下面的逐点扫描越界读。
|
||
// 本组件其它索引遍历(`HasVisibleSample` / `VisibleIndexRange` / `BuildPolyline`)
|
||
// 都做了同样的夹取。
|
||
begin = std::min(begin, count);
|
||
end = std::min(end, count);
|
||
|
||
// ⚠️ **性能:把样式指针提到循环之外**。`DataSet::Style(ref)` 每次调用都要做
|
||
// 指针解析与边界检查;放在循环体里等于**每个采样点解析两次**
|
||
//(实测全段自适应因此从 147ms 劣化到 1200ms —— 8 倍回归)。
|
||
const SignalStyle* resolvedStyle = style; // 可能为 nullptr(视为无变换)
|
||
const bool mirrored = (resolvedStyle != nullptr) && resolvedStyle->mirrorHorizontal;
|
||
const bool transform = (resolvedStyle != nullptr) &&
|
||
(resolvedStyle->gain != 1.0f || resolvedStyle->absolute ||
|
||
resolvedStyle->mirrorVertical || resolvedStyle->offsetY != 0.0f ||
|
||
resolvedStyle->HasFormula());
|
||
|
||
// ⚠️ **整段自适应直接取信号已缓存的 min/max**(O(1)):
|
||
// 当视界覆盖整条信号、且该信号没有任何数值变换时,逐点扫描的结果与
|
||
// `ComputeStats` 预存的 min/max 完全一致。工具栏「Y 轴自适应」开关在全视图下
|
||
// 会一次性对全部网格做这件事 —— 逐点扫描 2580 万 × 5 是秒级卡顿(实测 1200ms),
|
||
// 这里改为 O(1)。
|
||
if (axis->monotonic && begin == 0 && end == count && !mirrored && !transform &&
|
||
signal->finiteCount > 0)
|
||
{
|
||
low = std::min(low, signal->minValue);
|
||
high = std::max(high, signal->maxValue);
|
||
continue;
|
||
}
|
||
|
||
if (!mirrored && !transform)
|
||
{
|
||
// 无变换:与既有实现完全一致的紧凑循环(无逐点分支,便于向量化)。
|
||
for (std::size_t index = begin; index < end; ++index)
|
||
{
|
||
if (!axis->monotonic)
|
||
{
|
||
const double t = axis->time[index];
|
||
if (!std::isfinite(t) || t < sourceMin || t > sourceMax)
|
||
{
|
||
continue;
|
||
}
|
||
}
|
||
const double value = signal->values[index];
|
||
if (!std::isfinite(value))
|
||
{
|
||
continue;
|
||
}
|
||
low = std::min(low, value);
|
||
high = std::max(high, value);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// 有变换(系数 / 绝对值 / 镜像 / Y 偏移)或水平镜像:逐点套用与绘制**相同**的变换。
|
||
// ⚠️ 自适应必须基于**画出来的曲线**,否则调整系数/镜像后自适应范围会按原始数据算错
|
||
//(用户报告"自适应区域错误,看上去还是按照原始数据来的")。
|
||
// 水平镜像时画面上的第 index 个位置取源下标 count-1-index 的值。
|
||
for (std::size_t index = begin; index < end; ++index)
|
||
{
|
||
if (!axis->monotonic)
|
||
{
|
||
const double t = axis->time[index];
|
||
if (!std::isfinite(t) || t < sourceMin || t > sourceMax)
|
||
{
|
||
continue;
|
||
}
|
||
}
|
||
const std::size_t sourceIndex =
|
||
(mirrored && count > 0) ? ((count - 1) - index) : index;
|
||
if (sourceIndex >= count)
|
||
{
|
||
continue;
|
||
}
|
||
const double raw = signal->values[sourceIndex];
|
||
if (!std::isfinite(raw))
|
||
{
|
||
continue;
|
||
}
|
||
const double value = transform ? ApplyDrawTransform(*resolvedStyle, raw) : raw;
|
||
if (std::isfinite(value))
|
||
{
|
||
low = std::min(low, value);
|
||
high = std::max(high, value);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!std::isfinite(low) || !std::isfinite(high))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
// ⚠️ 留白必须与整段自适应(`TabConfig::ComputeLaneRange` → `PadRange`)**共用同一规则**
|
||
//(`PadValueRange`):否则「点过适应视图后再打开 Y 轴自适应」会因为留白不同
|
||
//(6% vs 5%)而算出不一样的区间,网格被无意义地重绘一次;
|
||
// 反向(关掉 Y 轴自适应再点适应视图)同样会变。
|
||
PadValueRange(low, high);
|
||
|
||
Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
|
||
lane.autoRange = false;
|
||
lane.yMin = low;
|
||
lane.yMax = high;
|
||
BuildLaneVisuals();
|
||
return true;
|
||
}
|
||
|
||
void PlotView::ApplySelectionAutoFit()
|
||
{
|
||
if (m_tab == nullptr || m_dataSet == nullptr)
|
||
{
|
||
return;
|
||
}
|
||
|
||
const std::vector<Lane>& lanes = m_tab->Lanes();
|
||
bool any = false;
|
||
for (const Lane& lane : lanes)
|
||
{
|
||
if (lane.fitYToSelection)
|
||
{
|
||
any = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!any)
|
||
{
|
||
return;
|
||
}
|
||
|
||
// 只对开启了「选区 Y 轴自适应」的网格重新匹配 Y 轴;
|
||
// 重新匹配依据的是刚刚更新过的时间视图范围。
|
||
for (std::size_t index = 0; index < lanes.size(); ++index)
|
||
{
|
||
if (m_tab->Lanes()[index].fitYToSelection)
|
||
{
|
||
AutoRangeLaneToView(static_cast<int>(index));
|
||
}
|
||
}
|
||
}
|
||
|
||
void PlotView::ReapplyGlobalAutoFitIfEnabled()
|
||
{
|
||
if (m_tab == nullptr || m_dataSet == nullptr)
|
||
{
|
||
return;
|
||
}
|
||
// 与工具栏「Y 轴自适应」按钮的勾选判据完全一致(全部网格都开启才叫“全局开启”)。
|
||
if (!AllLanesAutoFitToSelection())
|
||
{
|
||
return;
|
||
}
|
||
// 结构变化后重新匹配一次:新网格要按当前视界自适应,
|
||
// 移除信号后的网格也要按剩下信号重新收敛(旧幅值区间是按旧信号集合算的)。
|
||
ApplyAllAutoFitToSelectionNow();
|
||
}
|
||
|
||
bool PlotView::UpdateAutoScatterStyles()
|
||
{
|
||
if (m_dataSetWritable == nullptr || m_tab == nullptr)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
bool changed = false;
|
||
// 只处理当前标签页里**可见**的信号:绘制方式的自动切换是「视界」驱动的,
|
||
// 看不见的信号与当前视界无关(它们可见时会经 `RefreshLanes` 再判一次)。
|
||
for (const Lane& lane : m_tab->Lanes())
|
||
{
|
||
for (const SignalRef& ref : lane.signals)
|
||
{
|
||
if (!m_tab->IsVisible(ref))
|
||
{
|
||
continue;
|
||
}
|
||
const TimeAxis* axis = m_dataSetWritable->Axis(ref);
|
||
const SignalData* data = m_dataSetWritable->Resolve(ref);
|
||
SignalStyle* style = m_dataSetWritable->Style(ref);
|
||
if (axis == nullptr || data == nullptr || style == nullptr)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
// ⚠️ **只有一个有效采样点**的信号**不参与**自动切换:
|
||
// 它们的初始样式固定为「散点」(见 `ApplySinglePointDefaultStyle`),而信号面板的
|
||
// 「线段」/「点线」按钮已对其置灰(用户 2026-10-16 要求)——
|
||
// 若这里再自动写成「点线」,用户会看到一个“已置灰却仍处于点线态”的矛盾状态。
|
||
if (data->finiteCount == 1)
|
||
{
|
||
style->autoScatterActive = false;
|
||
style->autoScatterUserOverride = false;
|
||
continue;
|
||
}
|
||
|
||
// ⚠️ **先**判定“用户是否手动改过绘制方式”:本机制只会写入「点线」,
|
||
// 所以自动启用期间 mode 不等于「点线」只可能是用户改的。
|
||
// 这一步必须在下面的分支之前做 —— 用户改完样式可能**紧接着就缩出去**
|
||
// (例如直接点适应视图),那次调用的视界已经不再满足条件,若等到那时才判定,
|
||
// 恢复逻辑会先把用户的修改覆盖掉(用户明确要求不能这样)。
|
||
if (style->autoScatterActive && style->mode != DrawMode::LINE_SCATTER)
|
||
{
|
||
style->autoScatterUserOverride = true;
|
||
}
|
||
|
||
// 判定改为**用户 2026-10-17 重新定义的新规则**:按缩放后的横向时间轴网格划分小格,
|
||
// **只要存在任何一个小格内的数据点少于 5 个**就自动切「点线」(局部稀疏即提示);
|
||
// 不再满足时(适应视图、或把视界移到信号的稠密区)自动恢复原样式。
|
||
// ⚠️ 可见性同样按**显示时间**判断(含 `offsetTime`)。
|
||
// ⚠️ 「光标吸附」用的是**同一个判定**(`MinorCellSparse`),用户要求两者“同样的状态判定”。
|
||
if (MinorCellSparse(*axis, *data, style->offsetTime))
|
||
{
|
||
if (!style->autoScatterActive)
|
||
{
|
||
// ⚠️ **只有「线段」模式的信号才自动切「点线」**(用户 2026-10-17 要求):
|
||
// 本来就是「散点」的信号放大后**不**自动切换(散点已经足够提示数据点位置,
|
||
// 再叠上线段反而干扰);已经是「点线」的也不需要动。
|
||
// ⚠️ 用户在此期间手动改过样式时也不切换,避免“改完下一帧又被改回去”。
|
||
if (style->mode != DrawMode::LINE || style->autoScatterUserOverride)
|
||
{
|
||
style->autoScatterActive = false;
|
||
continue;
|
||
}
|
||
// 进入阈值:保存原绘制方式并自动切到「点线」提示用户数据点位置。
|
||
style->autoScatterSavedMode = style->mode;
|
||
style->autoScatterActive = true;
|
||
style->autoScatterUserOverride = false;
|
||
style->mode = DrawMode::LINE_SCATTER;
|
||
changed = true;
|
||
}
|
||
}
|
||
else if (style->autoScatterActive)
|
||
{
|
||
// 退出阈值(例如适应视图):恢复自动启用之前的绘制方式;
|
||
// 但用户手动改过时**不恢复**(尊重用户的选择)。
|
||
style->autoScatterActive = false;
|
||
if (!style->autoScatterUserOverride && style->mode != style->autoScatterSavedMode)
|
||
{
|
||
style->mode = style->autoScatterSavedMode;
|
||
changed = true;
|
||
}
|
||
style->autoScatterUserOverride = false;
|
||
}
|
||
}
|
||
}
|
||
return changed;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
|
||
} // namespace jdi
|
||
|