Files
Just-DataInspector/Components/PlotView/Src/PlotViewFeatures.cpp
T
DeepSeek V4.1 Flash 53b981a2a9 全项目大检查:修复数据层/UI 真 BUG、清理死代码与过期注释、并评估二进制体积
按「性能 / BUG / 错误 / 过期注释 / 命名与实际含义不符」四项对全仓做了一次审查,
四个只读子审查交叉核对后逐条回到源码确认再改。

一、数据层真 BUG
- MatH5:U8/U16/U32/U64 用逐字节 Cached() 拼装,而 Cached 只服务「完全落在同一个
  64KB 块内」的请求 => 跨块时静默读成 0(U32 整段为 0、U64 高 32 位为 0),分块 B-tree
  的 chunk 偏移/子节点地址被破坏且无任何报错。新增 ScalarLE():命中缓存用缓存,
  否则退回一次 ReadAt;U64 顺带从 8 次缓存查找降到 1 次。
- Mf4Loader::IsBlock 未校验链接表是否落在块内 => DataOffset 越界,随后
  block_len - (data - offset) 在 uint64 上下溢,得到一个约 2^64 的"可用字节数",
  下游 vector(usable+1) 直接 bad_alloc。同时把 offset + bytes > size 改成不产生回绕的写法。
- DecodeRawValue 的整数路径:bit_offset%8 != 0 且位宽 64 时需 9 个字节,先拼成 64 位
  再整体右移会把最高字节静默丢弃 => 改为逐字节带位偏移累加。
- cg_cycle_count 加「数据段总字节 / 记录字节」上限(压缩段按 deflate 最大膨胀比估算)。
- CsvLoader 表头分支同样跳过 #/% 注释行(否则整份文件按 1 列解析并被丢弃)。
- MatLoader:double -> int 的越界转换加了范围检查;普通对象递归加深度上限与成环检测。
- Utf8Util::BuildTicks:double -> long long 越界转换前先判商是否落在 ±9e18 内。

二、界面 BUG
- 工具栏按钮永远不显示悬停高亮:Paint 读的 Button::hover 全仓从未赋值,改为按 m_hoverIndex 判断。
- 菜单打开时"取消底层拖拽"对每个消息都执行,把菜单自己的 Target::EDIT_FIELD 也清掉
  => 菜单内联输入框无法拖拽框选。排除 EDIT_FIELD。
- GdiplusShutdown 之前未释放 PlotView 的 GDI+ 位图(Application 是进程单例,成员析构在
  WinMain 之后)=> 新增 PlotView::ReleaseGraphicsResources() 并在 Shutdown 里调用。
- WM_KILLFOCUS 现在会提交标签页/绘图区编辑并复位捕获与拖拽状态。
- WM_LBUTTONUP 不再抢走中键平移的鼠标捕获。
- TextEdit:被过滤器拒绝的字符返回 false;无选区时 Ctrl+X 只复制、不再清空整段文本。
- SignalPanel::InvalidateRows 作废编辑/待确认/改名计时等行下标状态。
- EndFilterEdit 改走 ApplyFilterText(否则输入纯空格后回车,列表仍停在筛选结果)。
- 新增 SignalBadgeVisible 作为唯一判据(原来命中只看"可见",绘制还要求"已分配网格")。
- 折起动画结束后夹取滚动量;双击分组名改名时清掉该组正在播放的折起动画。
- ScrollToSignal 复用 SignalScrollTarget(含悬浮组名避让)。
- RowNameRect 与绘制使用同一最小宽度(原来退化时返回空矩形)。
- 分隔条拖拽上限改用与 Relayout 同一公式;UpdateDragCursor 补 SPLITTER/EDIT_FIELD。
- SignalPanel 里几个守卫改为判断真正被解引用的指针;分组行 ✕ 也返回手型光标。

三、PlotView
- FindStyleOnlyLanes 改用完整的 SceneStyleEntry::operator==(原来只比 4 个字段,
  另一个网格同时改"调整"时带着旧像素被当成最新)。
- 宽基底图层不再烘焙拖拽光晕(LaneLayerHash 不含它),改由 PaintHoverHighlight 每帧叠加。
- PaintLaneLegend 提前返回前清掉本网格的命中矩形(否则能点中看不见的图例条目)。
- 面板主体改为完全不透明(消除平移后 4% 透出的旧波形,零额外代价)。
- PAN / Shift+滚轮改走 SetTimeView,使"自动点线"判据随平移重算。
- 内联输入框命中字体与绘制统一为 monoSmall。
- 派生曲线取值与窗口按 SourceIndexFor 映射(水平镜像时不再读到镜像另一端)。
- EdgePanelWidth 补 style/axis 空指针守卫;二分结果夹到 count。

四、清理
- 删除 Toolbar::ActionLabel、Theme::statusBg、PopupMenu 的 editResult 死机制、
  Toolbar::Layout 的重复 clear()、HitRow 的恒假分支、OnDoubleClick 的重复判定、
  PaintUtil 里孤立的注释块。
- 修正一批过期/错误注释(包络带"不做加宽"、LaneLayerHash 的图例说法、
  均值滤波曲线的窗口说法、样式区行数与「调整」按钮顺序、FIT_ALL 退出缩放模式、
  RemoveSignal 的 ✕ 位置、.mat/.dat 格式文档、MatH5 的"映射视图"、DataModel 的
  "点线/被移除文件"、SignalPanel 的"行高连续缩小"、TextEdit 的 PaintUtil 引用等)。

五、二进制体积评估(按要求只评估,未做压缩修改)
- release 809,472 字节;.text 652KB、.xdata+.pdata 约 75KB、.rdata 42KB、
  .rsrc 20KB、.idata 11.5KB。
- 去掉 -ld3d11 -ldwmapi -ld3dcompiler(仓内无任何调用)实测 0 字节收益
  (静态库只拉取被引用的对象)=> 删链接库不是压缩手段,只是配置整洁。
- 仓内已无 <iostream>/<sstream>/<filesystem>/<regex>/<locale> 等体积炸弹。
- 结论:已无明显的浪费可捡;要显著变小只能改变取舍(动态 CRT / UPX / 砍功能),
  需要用户决策,本轮未动。

验证
- editcheck 20/20、round3check 31/31、round4check 34/34、round5check 5/5、convcheck 18/18。
- check 136/137:唯一失败项经 git stash 回到改动前复现同一数值,属该探针自身的既有
  不稳定项(取决于前置用例留下的缓存/时序),与本轮改动无关。
- 真实数据:4 个 MF4 => 283/8/18/24 个信号;.mat 样例 8 信号、
  带无障碍对照-防夹力曲线数据 8 信号、BIG_MAT.mat 5 信号(私有内存 +741MB)。
- make release / make debug 零警告。

文档:Documents/agent_mem.md 同步 §10 变更日志、§11.2 第 171-180 条硬约束
与文末本轮小节(含体积评估数据)。
2026-10-20 16:10:29 +08:00

3223 lines
110 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
}
FitContribution item;
item.ref = ref;
// 与 `ComputeVisibleTimeBounds` 同源:时间边界用**时间轴**的范围
//(一个分组的多条信号可能共用一条轴,轴范围才是"这一段数据"的边界)。
item.minTime = axis->minTime;
item.maxTime = axis->maxTime;
item.singlePoint = (data->finiteCount == 1);
if (item.singlePoint)
{
double time = 0.0;
double value = 0.0;
if (!SinglePointOfSignal(*data, *axis, time, value))
{
item.singlePoint = false;
}
else
{
const SignalStyle* style = m_dataSet->Style(ref);
item.pointTime = time;
item.pointValue =
(style != nullptr) ? ApplyDrawTransform(*style, value) : value;
item.dotRadius = SinglePointDotRadius(ResolveSignal(ref));
}
}
perLane[laneIndex].push_back(item);
++total;
}
}
return total;
}
float PlotView::FitLaneHeight() const
{
// 与 `BuildLaneVisuals()` **完全同源**:网格高度 = 绘图区高 / 网格数(不低于最小高度)。
// 直接复用它(而不是读 `m_lanes[i].rect.Height`):`FitAll` 可能在网格数量刚变化、
// 布局尚未重建时被调用,用公式能拿到**将要生效**的高度。
const std::size_t count = (m_tab != nullptr) ? m_tab->Lanes().size() : 0;
if (count == 0 || !(m_plotRect.Height > 0.0f))
{
return 0.0f;
}
float height = m_plotRect.Height / static_cast<float>(count);
if (height < m_minLaneHeight * m_scale)
{
height = m_minLaneHeight * m_scale;
}
return height;
}
void PlotView::ApplySinglePointValueMargins(int laneIndex, const std::vector<FitContribution>& items,
float laneHeight)
{
if (m_tab == nullptr || m_dataSet == nullptr || laneHeight <= 1.0f)
{
return;
}
if (laneIndex < 0 || laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return;
}
bool anySingle = false;
for (const FitContribution& item : items)
{
if (item.singlePoint && item.dotRadius > 0.0f)
{
anySingle = true;
break;
}
}
if (!anySingle)
{
return; // 该网格的边界不是单值散点顶住的:保持现有(自动)区间
}
// 该网格当前**生效**的幅值区间(自动自适应时即 `ComputeLaneRange` 的结果)。
double low = 0.0;
double high = 0.0;
m_tab->ComputeLaneRange(*m_dataSet, laneIndex, low, high);
if (!(high > low))
{
return;
}
double newLow = low;
double newHigh = high;
// 迭代两遍:让出余量会改变跨度,而"半径换算成多少数值"又依赖跨度。
for (int pass = 0; pass < 2; ++pass)
{
const double span = newHigh - newLow;
if (!(span > 0.0))
{
break;
}
for (const FitContribution& item : items)
{
if (!item.singlePoint || !(item.dotRadius > 0.0f))
{
continue;
}
const double radius =
(static_cast<double>(item.dotRadius) + kSinglePointBoundaryPadPixels) * span /
static_cast<double>(laneHeight);
if (item.pointValue - radius < newLow)
{
newLow = item.pointValue - radius;
}
if (item.pointValue + radius > newHigh)
{
newHigh = item.pointValue + radius;
}
}
}
if (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;
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;
}
low = std::min(low, axis->minTime);
high = std::max(high, axis->maxTime);
}
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);
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);
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::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) 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;
}
// 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
{
// 统计还没扫完时主动请宿主再送一帧 —— 否则用户不移动鼠标就不会有任何重绘,
// 预算也就永远不推进(用户报告:"面板过去很久都一直显示统计中,
// 只有当鼠标瞄到浮动面板上时才会显示统计好的值")。
// 与派生曲线就绪用同一条消息(宿主只失效绘图区、不置脏,代价极小)。
const HWND host = m_host;
if (host != nullptr)
{
PostMessageW(host, kMessagePlotDerivedReady, 0, 0);
}
}
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;
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)
{
if (m_edgeScans.size() >= kEdgeScanStateLimit)
{
// ⚠️ 超限时**优先淘汰「已扫完」的最旧状态**(它重算很快、而且通常已经不需要再变),
// 绝不淘汰正在推进的那个 —— 否则其它信号会被迫从头重扫
//(那正是“改一个参数、其它几个信号也重新统计”这类现象)。
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();
}
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);
}
// ---- 中心轴:**就地**计算所需的那一小段均值滤波曲线 ---------------------
// ⚠️ 过去这里用 `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())
{
const std::size_t activeStates = std::max<std::size_t>(1, m_edgeScans.size());
const std::size_t share = std::max<std::size_t>(kEdgeScanFairShare, activeStates);
const std::size_t perState =
std::max<std::size_t>(kEdgeScanPairsPerStateMin, kEdgeScanPairsPerFrame / 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;
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;
}
}
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);
}
}
// ---- 汇总请求区间 -------------------------------------------------------
// 内部整块用块和相加,只有两端边界块(≤ 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);
// ⚠️ **整段自适应直接取信号已缓存的 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